// Gold Banking System — core schema // Canonical accounting model: legacy "System A" gram ledger (see migration notes). // The external "Tahesab" service is retired — LedgerEntry is the sole source of truth. // All money/weight fields use Decimal, never Float, to avoid rounding drift in debt math. generator client { provider = "prisma-client-js" // native = local Windows/macOS; debian-openssl-3.0.x = node:20-bookworm-slim (Arvan) binaryTargets = ["native", "debian-openssl-3.0.x"] } datasource db { provider = "postgresql" url = env("DATABASE_URL") } // ───────────────────────────────────────────────────────────────────────── // Enums // ───────────────────────────────────────────────────────────────────────── enum AdminRole { SUPER_ADMIN SYSTEM_APPROVER // legacy: تایید مدیر سیستم SALES_EXPERT // legacy Customer.type=1, also acts as a referral "reagent" WORKSHOP_SUPERVISOR ACCOUNTANT } /// Legacy Customer.type — what kind of wholesale customer this is. Values and /// labels taken verbatim from bulkCustomer.vue's userTypes list. enum CustomerKind { SALES_AGENT // 1 کارشناس فروش APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری BANKER // 4 بنکدار WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه } enum VerificationStep { UNVERIFIED STEP_ONE_SUBMITTED STEP_TWO_SUBMITTED VERIFIED } /// Legacy kept retail and wholesale orders in two SEPARATE tables /// (orders_orders vs orders). The redesigned schema has one Order model, so the /// distinction lives here instead. enum OrderChannel { RETAIL // تک فروشی WHOLESALE // عمده فروشی } enum OrderStatus { SUSPENDED // credit limit (goldCreditLimitGrams) exceeded at placement PLACED ADMIN_APPROVED SOURCING PREPARING DELIVERED_TO_AGENT DELIVERED_TO_CUSTOMER // debt recognition point — mirrors legacy sts>4 CANCELLED } enum PreOrderStatus { PLACED ADMIN_APPROVED WORKSHOP_APPROVED READY_TO_SHIP DELIVERED CANCELLED } enum LedgerCurrency { GOLD_GRAM RIAL } enum LedgerDirection { DEBIT // increases what the customer owes CREDIT // decreases what the customer owes } enum LedgerSourceType { ORDER_STOCK ORDER_PRE_ORDER REPAYMENT_MELTED_GOLD // karat-normalized: amount * karatPerMille / 750 REPAYMENT_TRANSFER REPAYMENT_CHEQUE REPAYMENT_CASH // price-per-gram-normalized via pricePerGramRialAtEntry WALLET_BONUS ADJUSTMENT } // ───────────────────────────────────────────────────────────────────────── // Identity // ───────────────────────────────────────────────────────────────────────── model City { id String @id @default(cuid()) name String province String? /// Legacy cities.percent / percent_aux — per-city wage surcharges the city /// screen edits through GET cities/get-percent/{id} and get-percent-aux/{id}. percent Decimal? @db.Decimal(6, 3) percentAux Decimal? @db.Decimal(6, 3) customers Customer[] addresses CustomerAddress[] branches Branch[] preOrders PreOrder[] } model AdminUser { id String @id @default(cuid()) fullName String mobile String @unique passwordHash String role AdminRole /// Legacy `users.ut`. Granular ACL lives here (Permission rows hang off UserType); /// the coarse `role` enum above is retained for the business rules that already /// switch on it. Nullable so existing seeded admins remain valid. userTypeId Int? userType UserType? @relation(fields: [userTypeId], references: [id]) branchId String? branch Branch? @relation(fields: [branchId], references: [id]) wagePercent Decimal? @db.Decimal(6, 3) // legacy Customer.percent, commission on referred orders isActive Boolean @default(true) createdAt DateTime @default(now()) referredCustomers Customer[] @relation("ReferralAgent") supervisedWorkshops Workshop[] @relation("WorkshopSupervisor") branchAllocations BranchAllocation[] @relation("AllocationAuthor") postedLedgerEntries LedgerEntry[] orderStatusEvents OrderStatusEvent[] preOrderStatusEvents PreOrderStatusEvent[] sentSmsMessages SmsMessage[] @relation("SmsAuthor") sentNotifications Notification[] @relation("NotificationAuthor") uploadedFiles UploadedFile[] @relation("FileUploader") @@index([role]) @@index([userTypeId]) } model Customer { id String @id @default(cuid()) fullName String mobile String @unique nationalCode String @unique passwordHash String cityId String? city City? @relation(fields: [cityId], references: [id]) /// Legacy Customer.type — distinguishes بنکدار from مغازه دار, which the /// BankerList / ShopkeeperList screens filter on. kind CustomerKind @default(APP_SHOPKEEPER) /// Legacy contact + shop columns, shown on the wholesale customer form and as /// (hidden-by-default) columns in its grid. phone String? address String? storeName String? pasazhName String? zone String? /// Legacy mcode — the operator-assigned customer code. memberCode String? referredByAgentId String? referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id]) // Credit limit + cached ledger balances (source of truth is LedgerEntry; these // are denormalized for fast reads and reconciled by a scheduled job). goldCreditLimitGrams Decimal @default(0) @db.Decimal(14, 4) // legacy gram_level goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain verificationStep VerificationStep @default(UNVERIFIED) nationalIdImageUrl String? businessLicenseUrl String? shopImageUrl String? /// Legacy `image_id` — the profile photo (عکس پروفایل) on the sales-agent /// form. Legacy stored an FK to its Image table and read it back as a nested /// `image: { id, image }`, so this is a relation, not a bare URL string. avatarFileId String? avatarFile UploadedFile? @relation("CustomerAvatar", fields: [avatarFileId], references: [id]) /// Legacy had TWO independent switches on a customer: /// sts -> may they place orders (this field) /// active -> is the account enabled (isActive, below) /// The port previously drove both from isActive; they are separate again. canPlaceOrders Boolean @default(true) isActive Boolean @default(true) /// Legacy archive flag — the archived list filtered on this, not on isActive. isArchived Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt addresses CustomerAddress[] orders Order[] preOrders PreOrder[] ledgerEntries LedgerEntry[] walletBonuses WalletBonus[] comments Comment[] promotions Promotion[] notifications NotificationRecipient[] activityLogs CustomerActivityLog[] @@index([referredByAgentId]) } model CustomerAddress { id String @id @default(cuid()) customerId String customer Customer @relation(fields: [customerId], references: [id]) cityId String? city City? @relation(fields: [cityId], references: [id]) addressLine String isDefault Boolean @default(false) } model Branch { id String @id @default(cuid()) name String /// Legacy Branch.address / phone — both are columns in the branch grid. address String? phone String? /// Legacy GET branch/active/{id} toggles this. isActive Boolean @default(true) cityId String? city City? @relation(fields: [cityId], references: [id]) adminUsers AdminUser[] orders Order[] allocations BranchAllocation[] } model Workshop { id String @id @default(cuid()) name String /// Legacy Workshop.address / phone — both are columns in the workshop grid. address String? phone String? /// The supervisor created alongside the workshop by the legacy Add form. supervisorId String? supervisor AdminUser? @relation("WorkshopSupervisor", fields: [supervisorId], references: [id]) isActive Boolean @default(true) preOrders PreOrder[] } // ───────────────────────────────────────────────────────────────────────── // Catalog — item reference catalog // ───────────────────────────────────────────────────────────────────────── model Category { id String @id @default(cuid()) title String /// Legacy Category.type — which storefront the category belongs to: /// 1 سایت | 2 اپلیکشن موبایل. Drives the "type_str" grid column. type Int @default(1) /// Legacy Category.image / icon — the grid renders the image, the storefront the icon. imageUrl String? iconUrl String? /// Legacy meta tag + description, used for SEO on the site categories. metaTag String? description String? parentId String? parent Category? @relation("CategoryTree", fields: [parentId], references: [id]) children Category[] @relation("CategoryTree") products Product[] promotions Promotion[] } model Brand { id String @id @default(cuid()) title String products Product[] promotions Promotion[] } /// Curated product groupings — replaces legacy "Ciny/CinyProduct" (a tagging /// feature, NOT a gold-price snapshot mechanism, despite the misleading legacy name). model Collection { id String @id @default(cuid()) title String description String? isActive Boolean @default(true) products CollectionProduct[] } model CollectionProduct { collectionId String collection Collection @relation(fields: [collectionId], references: [id]) productId String product Product @relation(fields: [productId], references: [id]) @@id([collectionId, productId]) } model Product { id String @id @default(cuid()) title String description String? categoryId String? category Category? @relation(fields: [categoryId], references: [id]) brandId String? brand Brand? @relation(fields: [brandId], references: [id]) /// Legacy Product.product_code — the human-facing SKU shown in the grid. productCode String? @unique /// Legacy Product.show_type: 1 site, 2 mobile, 3 both. showType Int @default(3) isActive Boolean @default(true) createdAt DateTime @default(now()) variants ProductVariant[] images ProductImage[] collections CollectionProduct[] comments Comment[] promotions Promotion[] allocationItems BranchAllocationItem[] } model ProductImage { id String @id @default(cuid()) productId String product Product @relation(fields: [productId], references: [id]) url String sortOrder Int @default(0) } model ProductVariant { id String @id @default(cuid()) productId String product Product @relation(fields: [productId], references: [id]) title String sku String? @unique /// Base/raw weight before making charge ("rash weight"). rashWeightGrams Decimal @db.Decimal(14, 4) /// Default making-charge percentage applied on top of rash weight. defaultWagePercent Decimal @db.Decimal(6, 3) /// Legacy ProductVariant.qty — on-hand stock. The cart refuses a variant with /// qty 0 unless it is flagged pre-order, so both fields are required for that /// guard to work (see apps/admin-panel/src/stores/cart.ts). qty Int @default(0) /// Legacy is_pre_order: orderable even at zero stock. isPreOrder Boolean @default(false) isActive Boolean @default(true) orderItems OrderItem[] } // ───────────────────────────────────────────────────────────────────────── // Orders — stock orders (debt-bearing) // ───────────────────────────────────────────────────────────────────────── model Order { id String @id @default(cuid()) orderNumber String @unique customerId String customer Customer @relation(fields: [customerId], references: [id]) branchId String? branch Branch? @relation(fields: [branchId], references: [id]) /// Which sales channel placed this order — see OrderChannel. channel OrderChannel @default(RETAIL) status OrderStatus @default(PLACED) /// Total gold weight incl. wage across all items ("allGram" / order.w). totalGoldWeightGrams Decimal @db.Decimal(14, 4) /// Pure making-charge weight component (totalGoldWeightGrams minus raw weight sum). wageWeightGrams Decimal @db.Decimal(14, 4) /// Flat Rial charge independent of gold price (e.g. stones) — legacy rial_input. flatRialCharge Decimal @default(0) @db.Decimal(20, 2) isSuspendedOverCreditLimit Boolean @default(false) deliveredAt DateTime? // setting this is what triggers debt recognition createdAt DateTime @default(now()) updatedAt DateTime @updatedAt items OrderItem[] statusEvents OrderStatusEvent[] ledgerEntries LedgerEntry[] convertedFromPreOrder PreOrder? @@index([customerId]) @@index([status]) } model OrderItem { id String @id @default(cuid()) orderId String order Order @relation(fields: [orderId], references: [id]) productVariantId String productVariant ProductVariant @relation(fields: [productVariantId], references: [id]) quantity Int // Snapshots at order time — never re-read from ProductVariant after placement. rashWeightGramsSnapshot Decimal @db.Decimal(14, 4) wagePercentSnapshot Decimal @db.Decimal(6, 3) discountPercentSnapshot Decimal @default(0) @db.Decimal(6, 3) /// computed: rash*(1 + (wagePercent-discount)/100) computedWeightGrams Decimal @db.Decimal(14, 4) flatRialCharge Decimal @default(0) @db.Decimal(20, 2) } model OrderStatusEvent { id String @id @default(cuid()) orderId String order Order @relation(fields: [orderId], references: [id]) fromStatus OrderStatus? toStatus OrderStatus note String? actorAdminUserId String? actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id]) createdAt DateTime @default(now()) @@index([orderId]) } // ───────────────────────────────────────────────────────────────────────── // Pre-orders — custom manufacturing requests (not debt-bearing until converted) // ───────────────────────────────────────────────────────────────────────── model PreOrder { id String @id @default(cuid()) customerId String customer Customer @relation(fields: [customerId], references: [id]) cityId String? city City? @relation(fields: [cityId], references: [id]) workshopId String? workshop Workshop? @relation(fields: [workshopId], references: [id]) status PreOrderStatus @default(PLACED) designImageUrl String? colorName String? sizeSpec String? stoneColor String? minaColor String? estimatedWeightGrams Decimal? @db.Decimal(14, 4) estimatedPriceRial Decimal? @db.Decimal(20, 2) description String? /// Once priced, an admin converts this into a real (debt-bearing) Order. convertedOrderId String? @unique convertedOrder Order? @relation(fields: [convertedOrderId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt statusEvents PreOrderStatusEvent[] @@index([customerId]) } model PreOrderStatusEvent { id String @id @default(cuid()) preOrderId String preOrder PreOrder @relation(fields: [preOrderId], references: [id]) /// Field-level diff, e.g. {"status": {"old": "PLACED", "new": "ADMIN_APPROVED"}} changes Json actorAdminUserId String? actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id]) ipAddress String? createdAt DateTime @default(now()) @@index([preOrderId]) } // ───────────────────────────────────────────────────────────────────────── // Accounting — the dual-currency ledger (core of the system) // ───────────────────────────────────────────────────────────────────────── model LedgerEntry { id String @id @default(cuid()) customerId String customer Customer @relation(fields: [customerId], references: [id]) currency LedgerCurrency direction LedgerDirection sourceType LedgerSourceType /// Always positive; direction determines the sign applied to the customer balance. amount Decimal @db.Decimal(20, 4) /// Purity of the physical instrument, for REPAYMENT_MELTED_GOLD (e.g. 750, 916). karatPerMille Int? /// Gold price/gram at conversion time, for REPAYMENT_CASH. pricePerGramRialAtEntry Decimal? @db.Decimal(20, 2) orderId String? order Order? @relation(fields: [orderId], references: [id]) /// Pending entries don't count toward the cached customer balance until confirmed. confirmed Boolean @default(false) confirmedAt DateTime? postedByAdminUserId String? postedByAdminUser AdminUser? @relation(fields: [postedByAdminUserId], references: [id]) /// Instrument metadata from the legacy "cash" document form. Which fields are /// filled depends on sourceType: a melted-gold receipt carries lab/hallmark and /// the pre-normalization weight, a cheque carries the account and bank, and a /// transfer carries its destination. /// Legacy columns: lab, ang, account_name, bank_name, to, original_value, recive_date. labName String? hallmark String? accountName String? bankName String? transferTo String? /// Weight as handed over, BEFORE karat normalization (legacy original_value). originalValue Decimal? @db.Decimal(14, 4) /// Rial amount for cash/cheque documents (legacy price). priceRial Decimal? @db.Decimal(20, 2) receivedAt DateTime? note String? createdAt DateTime @default(now()) @@index([customerId, currency]) @@index([orderId]) } /// Replaces legacy WebServiceGold / ConfigStatic.gold_price — timestamped price feed. model GoldPriceQuote { id String @id @default(cuid()) karatPerMille Int pricePerGramRial Decimal @db.Decimal(20, 2) source String quotedAt DateTime @default(now()) @@index([karatPerMille, quotedAt]) } /// Time-limited gold cashback bonus — replaces the legacy Rooyesh\Wallet gold credit. model WalletBonus { id String @id @default(cuid()) customerId String customer Customer @relation(fields: [customerId], references: [id]) orderId String? goldGrams Decimal @db.Decimal(14, 4) grantedAt DateTime @default(now()) expiresAt DateTime consumedAt DateTime? isActive Boolean @default(true) @@index([customerId, isActive]) } // ───────────────────────────────────────────────────────────────────────── // Admin ACL — faithful port of the legacy granular permission model. // // Legacy (henza_server): // Schema::create('permission', function (Blueprint $table) { // $table->string('rid'); // $table->integer('ut'); // $table->primary(['rid' , 'ut']); // }); // // Permissions attach to the USER TYPE (`ut`), never to an individual user, so // every admin of a given type shares one permission set. `ut` pointed at a row // in the legacy `config_static` lookup table; UserType is that table, narrowed // to the admin-user-type rows it was actually used for. // // A `rid` is a dotted permission id such as "persons.list.client.omde". The // frontend expands each stored rid into its ancestors at runtime via // checkParent() — see apps/admin-panel/src/stores/users.ts. Only the 58 leaf // rids the routers actually gate are seeded; ancestors are derived, not stored. // ───────────────────────────────────────────────────────────────────────── model UserType { /// Legacy `permission.ut` / `users.ut` — an integer, kept as the PK so seeded /// rows keep their legacy identity rather than getting new cuids. id Int @id title String isActive Boolean @default(true) createdAt DateTime @default(now()) permissions Permission[] adminUsers AdminUser[] @@map("user_type") } model Permission { /// Dotted permission id, e.g. "order.list", "persons.list.client.omde". rid String userTypeId Int userType UserType @relation(fields: [userTypeId], references: [id], onDelete: Cascade) /// Legacy's composite primary key (rid, ut), preserved exactly. @@id([rid, userTypeId]) @@index([userTypeId]) @@map("permission") } // ───────────────────────────────────────────────────────────────────────── // Product comments / reviews — legacy `comment` table (App\Models\Comment), // moderated from views/customer/Comments/Comments.vue. // // Legacy fillable: product_id, comment, reply_to, sts, customer_id // Legacy Comment::$STS = [1 => 'در انتظار تایید', 2 => 'تایید شده', 3 => 'رد شده'] // which is represented here as an enum rather than a magic integer. // ───────────────────────────────────────────────────────────────────────── enum CommentStatus { PENDING // 1 — در انتظار تایید APPROVED // 2 — تایید شده REJECTED // 3 — رد شده } model Comment { id String @id @default(cuid()) productId String product Product @relation(fields: [productId], references: [id]) customerId String customer Customer @relation(fields: [customerId], references: [id]) body String status CommentStatus @default(PENDING) /// Legacy `reply_to`: a staff reply threads under the comment it answers. replyToId String? replyTo Comment? @relation("CommentReplies", fields: [replyToId], references: [id]) replies Comment[] @relation("CommentReplies") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([productId]) @@index([customerId]) @@index([status]) } // ───────────────────────────────────────────────────────────────────────── // Settings — replaces legacy `config_static`, a key/value table the admin // panel read and wrote through endpoints like /wallet/config and // /const/__list__ut. Values are stored as JSON so a setting can hold a scalar, // an object, or the dynamic option lists legacy kept under `__list__*` keys. // ───────────────────────────────────────────────────────────────────────── model Setting { key String @id value Json updatedAt DateTime @updatedAt } // ───────────────────────────────────────────────────────────────────────── // Promotions / discounts — legacy views/promotion/*, served by // PromotionController (`product/variant/promotion`). The redesigned schema had // no promotion concept at all; this models what the legacy forms actually // collect. // ───────────────────────────────────────────────────────────────────────── /// Legacy Promotion.type — what the discount is scoped to. enum PromotionScope { PRODUCT // 1 محصولات CATEGORY // 2 دسته بندی CUSTOMER // 3 مشتری BRAND // 4 برند } /// Legacy Promotion.use_only — how often a customer may redeem it. enum PromotionUsage { ONCE // 1 فقط یکبار UNTIL_END_DATE // 2 تا تاریخ پایان } model Promotion { id String @id @default(cuid()) title String description String? scope PromotionScope @default(PRODUCT) usage PromotionUsage @default(ONCE) /// Percentage off, 0-100 (legacy discount_percent). discountPercent Decimal @db.Decimal(6, 3) isActive Boolean @default(true) /// Which storefront it shows on, mirroring Product.showType (legacy platform). platform Int @default(3) startsAt DateTime? endsAt DateTime? /// Exactly one of these is populated, per `scope`. productId String? product Product? @relation(fields: [productId], references: [id]) categoryId String? category Category? @relation(fields: [categoryId], references: [id]) customerId String? customer Customer? @relation(fields: [customerId], references: [id]) brandId String? brand Brand? @relation(fields: [brandId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([scope, isActive]) } /// Legacy "ignore" table (views/offersIgnore) — specific products, categories, /// customers or brands excluded from the automatic discount engine. /// Legacy posts { type, item_id[] }: "type" reuses the promotion scope codes /// (1 محصولات | 2 دسته بندی | 3 مشتری | 4 برند) and "itemId" is the excluded row. model PromotionExclusion { id String @id @default(cuid()) type Int itemId String createdAt DateTime @default(now()) @@unique([type, itemId]) @@index([type]) } // ───────────────────────────────────────────────────────────────────────── // Branch stock allocation — legacy views/invoice/* ("فاکتور انتقال"), served by // BranchController as `branch/product`. Despite the screen being called an // invoice, it is not a sales document: it records which products head office // allocated to a branch, and who allocated them. // ───────────────────────────────────────────────────────────────────────── model BranchAllocation { id String @id @default(cuid()) branchId String branch Branch @relation(fields: [branchId], references: [id]) /// Legacy `allocator_user` — the admin who filed the allocation. allocatorAdminUserId String? allocatorAdminUser AdminUser? @relation("AllocationAuthor", fields: [allocatorAdminUserId], references: [id]) note String? createdAt DateTime @default(now()) items BranchAllocationItem[] @@index([branchId]) } model BranchAllocationItem { id String @id @default(cuid()) allocationId String allocation BranchAllocation @relation(fields: [allocationId], references: [id], onDelete: Cascade) productId String product Product @relation(fields: [productId], references: [id]) /// Weight snapshot at allocation time, so later catalog edits do not rewrite history. weightGrams Decimal? @db.Decimal(14, 4) @@unique([allocationId, productId]) } // ───────────────────────────────────────────────────────────────────────── // Messaging + audit — legacy views/sms, views/notify, views/newsletters and // views/statics. None of these had a model in the redesigned schema. // ───────────────────────────────────────────────────────────────────────── /// Who an SMS or notification was aimed at. Legacy's sms/create offered these /// audiences, each gated by its own rid (sms.create.all, .omde, .single, ...). enum MessageAudience { SINGLE_NUMBER // یک شماره MULTIPLE_NUMBERS // چند شماره ALL_CUSTOMERS // همه WHOLESALE_CUSTOMERS // عمده RETAIL_CUSTOMERS // تک SALES_AGENTS // کارشناسان فروش } model SmsMessage { id String @id @default(cuid()) audience MessageAudience /// Comma-separated recipients for the SINGLE/MULTIPLE audiences. recipients String? body String /// How many numbers the send actually resolved to. sentCount Int @default(0) sentByAdminUserId String? sentByAdminUser AdminUser? @relation("SmsAuthor", fields: [sentByAdminUserId], references: [id]) createdAt DateTime @default(now()) @@index([createdAt]) } model Notification { id String @id @default(cuid()) title String body String audience MessageAudience @default(ALL_CUSTOMERS) sentByAdminUserId String? sentByAdminUser AdminUser? @relation("NotificationAuthor", fields: [sentByAdminUserId], references: [id]) createdAt DateTime @default(now()) recipients NotificationRecipient[] @@index([createdAt]) } /// Per-customer delivery + read state — legacy `GET notify/read/{id}`. model NotificationRecipient { id String @id @default(cuid()) notificationId String notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade) customerId String customer Customer @relation(fields: [customerId], references: [id]) readAt DateTime? @@unique([notificationId, customerId]) @@index([customerId, readAt]) } /// Legacy views/newsletters — the mailing-list signups collected by the site. model NewsletterSubscriber { id String @id @default(cuid()) email String? mobile String? createdAt DateTime @default(now()) @@unique([email]) @@unique([mobile]) } /// Legacy views/statics — the customer activity log the "آمار" screen reads /// (`log/list/customer`, `log/ago/{customerId}`). model CustomerActivityLog { id String @id @default(cuid()) customerId String customer Customer @relation(fields: [customerId], references: [id]) /// Free-form action key, e.g. 'login', 'order.placed', 'profile.updated'. action String detail String? createdAt DateTime @default(now()) @@index([customerId, createdAt]) } // ───────────────────────────────────────────────────────────────────────── // Uploads — replaces the legacy MyUpload / SingleUploadInput widgets, which // POSTed to a file endpoint and stored the returned URL on the owning record. // Ports of those forms currently take a URL by hand; this gives them a real // destination to upload to. // ───────────────────────────────────────────────────────────────────────── model UploadedFile { id String @id @default(cuid()) /// Public URL the stored file is served from. url String originalName String mimeType String sizeBytes Int /// Free-form tag for what the file belongs to, e.g. 'product', 'category', /// 'customer.nationalId'. Kept loose so a new form needs no schema change. purpose String? uploadedByAdminUserId String? uploadedByAdminUser AdminUser? @relation("FileUploader", fields: [uploadedByAdminUserId], references: [id]) createdAt DateTime @default(now()) customerAvatars Customer[] @relation("CustomerAvatar") @@index([purpose]) }