Files
tehran_tala/apps/api/prisma/schema.prisma
T
s.esmaeiliandClaude Opus 5 cbb93dec5d feat(catalog): page and filter the storefront server-side (steps 1-2)
The real catalogue reaches ~16k products and GET /catalog/products had no
paging parameters at all: it returned every active product with its
variants, images, category, brand and collections in one response. The
apps' PAGE_SIZE = 20 was only a render window over that full array, so
scrolling made zero requests. The reference paged this server-side; the
port had moved it to the client (STUB-03), so this is a return to
reference behaviour.

Step 1 — indexes, which were not optional. Product carried NO index
whatsoever (not isActive, categoryId, brandId or createdAt) and
ProductVariant had none on productId, so every per-product include
scanned its whole table. Adding take/skip on top of that would have
paged the response without making the query faster, which is the exact
failure this change exists to avoid. Product_isActive_createdAt_idx is
composite so one index serves the filter, the default ordering and the
OFFSET walk.

CREATE INDEX, not CONCURRENTLY: the deploy image's migration runner
wraps each file in a transaction, which CONCURRENTLY cannot join (D15).
Every statement is IF NOT EXISTS because both migration paths run it.

Step 2 — the routes. Paging is opt-in, which is the whole
backward-compatibility story: with no query parameters the route returns
the same plain array from the same cache as before, so the admin panel,
the smoke scripts and installed app builds are untouched. Pass page,
limit or any filter and it becomes { rows, total, page, limit }.

The filters had to move too, not just the paging. The apps filtered
title, category, brand and gram range in the browser over the full
array; paging the response while leaving that in place would have made
search quietly WRONG — searching only the first page and reporting
"محصولی پیدا نشد" for products that exist. So every filter is built into
the Prisma query, and count uses the same where clause, so `total`
describes the set the app is actually scrolling.

Three routes added so the apps can stop fetching everything to find one
thing (wired up in step 3): ?ids=a,b,c for the cart, favourites and
order items; /:id for the product screen, which today downloads the
whole catalogue to find one row; /facets for the filter sheet's options,
which the apps currently derive by scanning the catalogue in the
browser.

Deliberately not cached: CatalogCacheService exists because the unpaged
query is expensive and every screen wanted it. A paged, filtered query
is neither — bounded by limit and index-served — and caching it would
need a key per filter combination, i.e. unbounded memory for cheap
queries plus a second place for stale rows to hide.

D27's two storefront rules (active, has an image) live in one shared
where clause, so the list, its count, the single-product route, by-ids
and the facets can never disagree about what is visible. limit and ids
are both capped at 100; an uncapped limit is just the old "give me
everything" query spelled differently.

17 new cases, 329/329 overall. All 17 passed on the first run, which is
not evidence by itself, so one filter was deliberately broken to confirm
the suite catches it (1 failed/16 passed) and then restored.

Not measured on real data — the database container was not running, so
16k-row behaviour is reasoned from the query plan, not timed. Worth one
EXPLAIN ANALYZE on a full dataset before step 4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-12 15:01:32 +03:30

1444 lines
60 KiB
Plaintext

// 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.
///
/// SALES_AGENT is a LEGACY value only: legacy stored a sales agent as a
/// Customer row with type=1, but the redesigned system models a sales agent
/// as an AdminUser (role SALES_EXPERT) instead — see admin-users.service.ts
/// and admin-customers.service.ts's listReagents()/create(). No Customer row
/// should be created with kind SALES_AGENT going forward; the value is kept
/// only so legacyTypeToKind()/kindToLegacyType() stay total functions and any
/// already-migrated data keeps a readable label.
///
/// RETAIL marks a "تک" (single/retail) customer — legacy kept these in an
/// entirely separate table (SingleCustomer/`login`) that could never contain
/// a wholesale/agent/shopkeeper/banker row. The redesigned schema folded both
/// into one Customer model, so this value is what listSingle() now filters on
/// to reproduce that same separation.
enum CustomerKind {
SALES_AGENT // legacy 1 کارشناس فروش — superseded by AdminRole.SALES_EXPERT, do not use for new rows
APP_SHOPKEEPER // 2 مغازه دار اپلیکیشن
WALK_IN_SHOPKEEPER // 3 مغازه دار حضوری
BANKER // 4 بنکدار
WORKSHOP_SUPERVISOR // 5 سرپرست کارگاه
RETAIL // مشتری تک — legacy SingleCustomer
}
enum VerificationStep {
UNVERIFIED
STEP_ONE_SUBMITTED
STEP_TWO_SUBMITTED
VERIFIED
/// Legacy had THREE states per step, not two
/// (MajorCustomerController.php:106-135): 1 = awaiting, 2 = approved,
/// 3 = REJECTED. Mapping rejected customers to UNVERIFIED would put closed
/// cases back in the review queue, so the owner ruled it out (2026-10-07).
/// 14 legacy rows land here; see Customer.verificationRejectionReason.
REJECTED
}
/// 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
// ─────────────────────────────────────────────────────────────────────────
/// Legacy `cities` is a tree (country > province > city > district) and so is
/// this: a city hangs under its `parent`. In the admin pickers a top-level row
/// (the country) is a heading, not selectable (henza_admin citiesModel.js:38).
model City {
id String @id @default(cuid())
name String
parentId String?
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Restrict)
children City[] @relation("CityTree")
/// 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[]
salesExpertProfiles SalesExpertProfile[]
@@index([parentId])
}
model AdminUser {
id String @id @default(cuid())
/// ⚠️ Kept in sync BY A DATABASE TRIGGER — `admin_user_sync_full_name`, from
/// migration 20260922180000. Prisma cannot see it, so a write here may store
/// something other than what you passed:
///
/// * write the parts -> fullName is rebuilt as "first last"
/// * write fullName only -> the parts are re-derived from it
/// * write BOTH in one update -> THE PARTS WIN; the submitted fullName is
/// discarded
///
/// Re-read after writing if you need the stored value. Same arrangement as
/// Customer (D0 requirement 7 — the split applies to ALL admins, not only
/// sales experts).
fullName String
firstName String?
lastName 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())
/// Legacy stored a sales agent as a `customers` row with type=1, so
/// ReagentCustomer.vue's shared form collected these alongside the
/// wholesale-customer fields. A sales agent is now an AdminUser
/// (role=SALES_EXPERT), so these live here instead — only ever set for that
/// role; every other AdminUser leaves them null.
avatarFileId String?
avatarFile UploadedFile? @relation("AdminUserAvatar", fields: [avatarFileId], references: [id])
address String?
storeName String? // legacy store_name
pasazhName String? // legacy pasazh_name
memberCode String? // legacy mcode
referredCustomers Customer[] @relation("ReferralAgent")
/// Orders credited to this sales expert — see Order.salesAgentId.
creditedOrders Order[] @relation("OrderSalesAgent")
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")
/// Only ever set for role = SALES_EXPERT (D17).
salesExpertProfile SalesExpertProfile?
/// Issued admin-panel tokens, recorded only while session tracking is on
/// (AUTH_SESSION_TRACKING, or implicitly whenever the IAM is enabled) so a
/// token can be revoked before it expires. See AdminSession.
sessions AdminSession[]
@@index([role])
@@index([userTypeId])
@@index([avatarFileId])
@@index([firstName])
@@index([lastName])
}
/// An issued APP token (customer app / sales-expert app), so it can be revoked
/// before it expires — the counterpart of AdminSession for the other token family.
///
/// WHY A SEPARATE TABLE AND NO FOREIGN KEY
/// ---------------------------------------
/// An app token's `sub` is a Customer id for a customer and an **AdminUser id**
/// for a sales expert (D21). One FK cannot point at two tables, so the subject is
/// stored as a plain id plus its type. The rows are short-lived operational data,
/// not part of the accounting model, so the missing FK costs nothing here.
model AppSession {
id String @id @default(cuid())
/// The token's `jti` claim. Unique: one row per issued token.
jti String @unique
/// Customer.id, or AdminUser.id when subjectType is "sales-expert".
subjectId String
subjectType String
mobile String?
/// Set when the login was confirmed by the central IAM: its session id (`sid`)
/// and stable user id (`sub`), which is what a back-channel logout arrives with.
iamSessionId String?
iamSub String?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
expiresAt DateTime?
lastSeenAt DateTime?
revokedAt DateTime?
revokedReason String?
@@index([subjectId])
@@index([iamSessionId])
@@index([iamSub])
@@index([expiresAt])
}
/// An issued admin-panel JWT, so it can be revoked before it expires.
///
/// WHY THIS EXISTS
/// ---------------
/// The admin token is self-contained and valid for 12h, which means nothing —
/// not disabling the account, not a central "sign out everywhere" — could cut a
/// session short: the token kept working until it expired. With the central IAM
/// in the loop that gap became visible (an IAM session revocation had no effect
/// here), so every issued token now carries a `jti` recorded on this table and
/// JwtStrategy refuses a token whose row is revoked.
///
/// Rows are written ONLY while tracking is enabled, and JwtStrategy only
/// enforces them while it is enabled, so a deployment that does not want the
/// extra lookup behaves exactly as before.
model AdminSession {
id String @id @default(cuid())
/// The token's `jti` claim. Unique: one row per issued token.
jti String @unique
adminUserId String
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
/// Set when the login was verified by the IAM: its session id (`sid`) and the
/// stable user id (`sub`) there. Back-channel logout arrives keyed by these —
/// deliberately here rather than as columns on AdminUser, so enabling the
/// feature needs no change to an existing table.
iamSessionId String?
iamSub String?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
expiresAt DateTime?
lastSeenAt DateTime?
revokedAt DateTime?
revokedReason String?
@@index([adminUserId])
@@index([iamSessionId])
@@index([iamSub])
@@index([expiresAt])
}
/// The sales-expert fields legacy kept on the `customers` row (type=1) and that
/// were lost when a sales expert became an AdminUser (D17).
///
/// A separate table rather than columns on AdminUser: these are meaningful only
/// for role = SALES_EXPERT and would be permanently null for every other admin.
/// A row is created for every SALES_EXPERT by the migration, and upserted by the
/// service whenever one is created or a user's role becomes SALES_EXPERT — but
/// readers still left-join, so a missing profile degrades to nulls rather than
/// dropping the agent from the grid.
///
/// `reagent_id` is deliberately NOT here — see D17: in the reference it was a
/// side effect of the shared customers form, and filling it removed that expert
/// from the selectable-agent list (queryReagent, AdminController.php:420).
model SalesExpertProfile {
id String @id @default(cuid())
adminUserId String @unique
adminUser AdminUser @relation(fields: [adminUserId], references: [id], onDelete: Cascade)
/// Legacy customers.city_id — a filterable grid column
/// (ReagentListModel.js:14) and the source of the commission autofill
/// (ReagentCustomer.vue:215-228).
cityId String?
city City? @relation(fields: [cityId], references: [id])
/// Legacy customers.gram_level — the «سطح اعتبار» column, filterable.
/// Decimal(14,4) matches Customer.goldCreditLimitGrams exactly so the same
/// credit limit has identical precision on both tables.
gramLevel Decimal @default(0) @db.Decimal(14, 4)
/// Legacy customers.phone (varchar 15) and ncode (varchar 20). The reference
/// stores both and reads neither back; kept so the form matches it. Neither
/// is unique and neither has a validation rule in the reference — inventing
/// one would reject data the reference accepts.
phone String? @db.VarChar(15)
nationalCode String? @db.VarChar(20)
/// The Customer row carrying this expert's OWN orders, debt and credit limit.
///
/// The reference stored a sales agent AS a Customer (type=1), so an order the
/// agent placed from the app was simply their own, against their own
/// gram_level (ApiMobileController.php:534-535). Here the expert is an
/// AdminUser (D17), which has no ledger and cannot be an Order's subject, so
/// this one linked Customer plays that part.
///
/// Distinct from the expert's REFERRED customers, which are ordinary Customer
/// rows pointing at the AdminUser through Customer.referredByAgentId.
///
/// Null until the expert first signs into the app, when it is created from
/// this profile. See D21.
tradingCustomerId String? @unique
tradingCustomer Customer? @relation("SalesExpertTradingAccount", fields: [tradingCustomerId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@index([cityId])
}
model Customer {
id String @id @default(cuid())
/// ⚠️ `fullName`, `firstName` and `lastName` are kept in sync BY A DATABASE
/// TRIGGER — `customer_sync_full_name`, created in migration
/// 20260922120000_customer_name_parts. Prisma does not know about it, so a
/// write here is NOT the whole story:
///
/// * write the parts -> fullName is rebuilt as "first last"
/// * write fullName only -> the parts are re-derived from it
/// * write BOTH in one update -> THE PARTS WIN; fullName is overwritten
/// with "first last" and whatever you passed for fullName is discarded
///
/// So a value you wrote can differ from the value that lands in the row.
/// Re-read after writing if you need the stored value. Prefer writing the
/// parts; `fullName` exists for the legacy readers that predate D0.
///
/// Reference has these as two columns from the start
/// (henza_server 2020_10_01_120430_customers_table.php:18-19 fname/lname);
/// the port collapsed them, which is the D0 defect.
fullName String
firstName String?
lastName String?
mobile String @unique
/// Legacy customers.ncode. OPTIONAL since 2026-10-07: not one of the 1,616
/// legacy customers has a national code (1595/1595 empty), and fabricating
/// one is a data violation that would block later legal identity checks
/// (owner ruling). Still @unique — in PostgreSQL NULLs do not collide, so
/// the migration tool must write NULL and never '', which would collide on
/// the second row.
///
/// Three service-layer workarounds exist ONLY because this used to be
/// required, and they store the MOBILE NUMBER here:
/// admin-customers.service.ts:625,685 and customer-auth.service.ts:258.
/// Retiring them is a separate change; see docs/DATA-MIGRATION-MAP.md §10.4.
nationalCode String? @unique
passwordHash String
cityId String?
city City? @relation(fields: [cityId], references: [id])
/// Legacy customers.new_city — the city name exactly as the customer typed it
/// at app sign-up (Major/CustomerController.php:36). Kept verbatim because
/// the legacy city table has only 15 rows, so the typed name is often the only
/// record of where the customer is (172 customers; 43 match no known city).
/// cityId is set alongside it only on an exact, ي/ك-normalised name match.
/// Legacy sign-up also forced city_id = 5 (Tehran) on everyone
/// (Major/CustomerController.php:21), so for those rows the typed name, not
/// cityId, is the truth. Owner ruling 2026-10-07: keep both.
cityNameRaw String?
/// 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])
/// Set only on the one Customer row that acts as a sales expert's own trading
/// account (see SalesExpertProfile.tradingCustomerId). Empty for every
/// ordinary customer.
salesExpertTradingFor SalesExpertProfile? @relation("SalesExpertTradingAccount")
/// Legacy orders that named THIS customer as their referrer — see
/// Order.referrerCustomerId. History only.
referredOrders Order[] @relation("OrderReferrerCustomer")
// 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
/// Balance as last read from the accounting software (D27), in the app's own
/// sign convention (positive = owes). Shown to the customer when the
/// integration is on; the credit-limit check still uses goldDebtGrams above.
/// Null until the first successful sync.
accountingGoldBalance Decimal? @db.Decimal(14, 4)
accountingRialBalance Decimal? @db.Decimal(20, 2)
accountingBalanceAt DateTime?
/// Legacy `customers.percent` — the per-customer making-charge offset. It is
/// ADDED to the variant's making charge when pricing an order line
/// (henza_pwa src/Mixins/product/index.ts:69-75:
/// getWage(v) = state.percent + v.percentage_pay)
/// and it shifts the catalogue making-charge filter bounds
/// (ApiMobileController.php:391-396,76-77). It is NOT part of the price
/// formula itself — that extra percent is ConfigStatic['product_price_extra_percent'].
///
/// NOT the wallet cashback rate (storefront ConfigStatic['percent'], D13) and
/// NOT the wholesale per-line charge (OrderItem.wagePercentSnapshot).
///
/// DB default 0 keeps every existing customer's price unchanged. The
/// reference's application-layer default for NEW customers is 3
/// (AdminController.php:338) and the field is required on create (:354).
makingChargeOffsetPercent Decimal @default(0) @db.Decimal(6, 3)
verificationStep VerificationStep @default(UNVERIFIED)
nationalIdImageUrl String?
businessLicenseUrl String?
shopImageUrl String?
/// Why verification was rejected (VerificationStep.REJECTED).
///
/// Legacy recorded NO reason: none of its 49 `customers` columns is a
/// reason/note field, and on rejection it only sent a fixed SMS. So the 14
/// rows migrated from the legacy system carry the sentinel
/// «رد شده در سیستم قدیم — فاقد شرح علت ثبت‌شده» instead of NULL, so support
/// knows why it is blank (owner ruling 2026-10-07). The migration tool
/// writes it only WHERE this IS NULL, so a re-run cannot overwrite a real
/// reason entered in the new system.
verificationRejectionReason String?
/// NULL for rows migrated from the legacy system — it recorded no rejection time.
verificationRejectedAt DateTime?
/// Legacy customers.birth_date, populated on 917 of 1,595 rows. Kept for the
/// birthday SMS / loyalty campaigns (owner ruling 2026-10-07).
///
/// ⚠ Legacy stores dates as JALALI STRINGS in other tables
/// (cashes.recive_date is varchar, 88/89 values starting "14…"), so this
/// timestamp may hold mis-entered Jalali years. Confirm the calendar with
/// docs/migration-precheck-3.sql BEFORE backfilling: copying them unchanged
/// would produce 917 wrong birthdays, worse for an SMS campaign than none.
birthDate DateTime?
/// 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[]
otpChallenges CustomerOtp[]
favorites CustomerFavorite[]
@@index([referredByAgentId])
@@index([firstName])
@@index([lastName])
}
/// Legacy `fav` (251 rows) — saved products. The target had no model for this,
/// so every customer's favourites would have been lost in the migration; the
/// owner confirmed on 2026-10-06 that they matter.
///
/// Legacy `fav` has two columns and NO primary key. The composite key here
/// supplies the constraint the source lacks (the precheck found 0 duplicate
/// pairs, but the tool should still SELECT DISTINCT). 14 of the 251 rows point
/// at a deleted customer and must be skipped.
///
/// Legacy's denormalised `product.count_fav` counter is deliberately NOT
/// recreated as a column — derive it from this table.
model CustomerFavorite {
customerId String
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
productId String
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@id([customerId, productId])
@@index([productId])
}
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())
/// Stock of the linked کارساخته as last read from the accounting software
/// (D27). Weight for gram-type items, count for count-type ones — whichever
/// the software reports. At zero or below the product is unavailable in the
/// app (pre-order variants excepted). Null = not linked or never synced.
accountingStockWeight Decimal? @db.Decimal(14, 4)
accountingStockCount Decimal? @db.Decimal(14, 4)
accountingStockAt DateTime?
variants ProductVariant[]
images ProductImage[]
collections CollectionProduct[]
comments Comment[]
promotions Promotion[]
allocationItems BranchAllocationItem[]
favoritedBy CustomerFavorite[]
// Storefront paging (owner: the real catalogue reaches ~16k products, so
// GET /catalog/products must page rather than return everything). This model
// had NO index at all, so every storefront query was a full scan and paging
// alone would not have been any faster.
//
// The composite matches the list's own shape: filter on isActive, order by
// createdAt — Postgres can satisfy both from this one index, including the
// OFFSET walk. The other two serve the category and brand filters.
@@index([isActive, createdAt])
@@index([categoryId])
@@index([brandId])
}
model ProductImage {
id String @id @default(cuid())
productId String
product Product @relation(fields: [productId], references: [id])
url String
sortOrder Int @default(0)
/// Legacy had 4 distinct upload slots (تصویر شاخص / تصویر شاخص موبایل /
/// گالری تصاویر سایت / گالری تصاویر موبایل) — GALLERY is the default so
/// existing rows and any generic "just attach an image" caller keep working.
role ProductImageRole @default(GALLERY)
/// Variants that display this image. A gallery image may back several
/// variants (one photo, several weights), which is why this is a plain
/// one-to-many and not a join table.
variants ProductVariant[]
// The storefront list filters on `images: { some: {} }` and includes the
// gallery ordered by sortOrder, both per product. Unindexed, each one scanned
// the whole table.
@@index([productId, sortOrder])
}
enum ProductImageRole {
FEATURED
FEATURED_MOBILE
GALLERY
GALLERY_MOBILE
}
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)
/// The gallery image shown for this variant, picked from its own product's
/// images — never a re-uploaded copy. onDelete: SetNull because dropping an
/// image must never cascade into the variant: OrderItem rows reference
/// variants, so losing one would break order history.
imageId String?
image ProductImage? @relation(fields: [imageId], references: [id], onDelete: SetNull)
orderItems OrderItem[]
@@index([imageId])
// Every storefront product carries its variants, so this include ran once per
// product with no index to use. The composite covers the `isActive: true`
// filter the list applies to them.
@@index([productId, isActive])
}
// ─────────────────────────────────────────────────────────────────────────
// 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
/// The sales expert this order is credited to, fixed when it is placed:
/// the customer's referring expert AT THAT MOMENT. Legacy `orders.reagent_id`,
/// set once at creation (ApiMobileController.php:601) and never moved, so a
/// customer changing expert later does not move past sales or commission
/// (owner ruling 2026-10-08). «میزان فروش» counts from here, never from the
/// customer's current referrer.
salesAgentId String?
salesAgent AdminUser? @relation("OrderSalesAgent", fields: [salesAgentId], references: [id], onDelete: SetNull)
/// History only: a legacy order whose `reagent_id` named a customer who is
/// not a sales expert (one such customer, 17 orders in 2022). Legacy counted
/// those orders in that customer's own «میزان فروش»; this keeps that figure
/// exact. Never set for a new order.
referrerCustomerId String?
referrerCustomer Customer? @relation("OrderReferrerCustomer", fields: [referrerCustomerId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items OrderItem[]
statusEvents OrderStatusEvent[]
ledgerEntries LedgerEntry[]
convertedFromPreOrder PreOrder?
@@index([customerId])
@@index([status])
@@index([salesAgentId])
@@index([referrerCustomerId])
}
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
}
// ─────────────────────────────────────────────────────────────────────────
// Accounting integration (D27) — the shop's accounting software, Tahesab or
// Zarrin, chosen in the admin panel. Configuration lives in the Setting row
// `accounting.config` (never served by the generic `const` routes). Plan:
// docs/ACCOUNTING-INTEGRATION-PLAN.md.
//
// `provider` is the driver name ('tahesab' | 'zarrin'), a plain string like
// SMS_PROVIDER so a new driver needs no migration. Links are per provider, so
// switching provider and back loses nothing.
// ─────────────────────────────────────────────────────────────────────────
enum AccountingEntityType {
CUSTOMER
PRODUCT
}
/// One app row ↔ one record in the accounting software (customer account or
/// کارساخته).
model AccountingLink {
id String @id @default(cuid())
provider String
entityType AccountingEntityType
entityId String
/// The record's code in the accounting software (Tahesab Moshtari_Code /
/// Zarrin idHesab; for products the Zarrin idKar or the Tahesab Name_Kar).
externalCode String
externalName String?
/// How the link came to be: LINKED (matched by mobile/name), IMPORTED (app
/// row created from the accounting record), PUSHED (accounting record created
/// from the app row) or MANUAL (an admin typed the code).
origin String
/// The record as last read, for display only.
snapshot Json?
syncedAt DateTime @default(now())
createdAt DateTime @default(now())
@@unique([provider, entityType, entityId])
@@unique([provider, entityType, externalCode])
@@index([entityType, entityId])
}
enum AccountingOutboxKind {
CUSTOMER_PUSH
PRODUCT_PUSH
ORDER_DOCUMENT
ORDER_DOCUMENT_DELETE
}
enum AccountingOutboxStatus {
PENDING
/// A call was in flight. Found at startup, it means the process died mid-call
/// and the outcome is unknown — see AccountingOutboxWorker.
SENDING
SENT
FAILED
/// Unsafe to retry automatically (e.g. a Zarrin call timed out: Zarrin has no
/// idempotency key, so a blind retry could post the document twice).
NEEDS_REVIEW
/// Closed by an admin from the log screen.
RESOLVED
}
/// Work to push to the accounting software. Written in the SAME transaction as
/// the change that causes it, so an outage of the accounting software never
/// loses an event and never blocks the app.
model AccountingOutbox {
id String @id @default(cuid())
provider String
kind AccountingOutboxKind
entityId String
status AccountingOutboxStatus @default(PENDING)
attempts Int @default(0)
nextAttemptAt DateTime @default(now())
lastError String?
/// What the accounting software returned as the document/record id.
externalRef String?
/// Per-step progress of multi-call jobs (a Zarrin document is one call per
/// line), so a failure resumes where it stopped.
progress Json?
/// Last request/response, for the log screen. Never contains the token.
lastRequest Json?
lastResponse Json?
sentAt DateTime?
resolvedById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, nextAttemptAt])
@@index([kind, entityId])
}
/// Something a pull found that needs a human: an account with no mobile, two
/// accounts sharing one mobile, a کارساخته group with no category mapping.
model AccountingIssue {
id String @id @default(cuid())
provider String
kind String
externalCode String
entityId String?
message String
data Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resolvedAt DateTime?
@@unique([provider, kind, externalCode])
@@index([resolvedAt])
}
// ─────────────────────────────────────────────────────────────────────────
// 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")
adminUserAvatars AdminUser[] @relation("AdminUserAvatar")
@@index([purpose])
}
/// One OTP login challenge for a customer.
///
/// Ports henza_server's flow (ApiMobileController.php:39-78), where userAuth
/// generated rand(1000,9999), stored it on the customer row as `send_password`
/// and sent it by SMS, and userVerfiy compared the submitted code against it
/// (mismatch -> HTTP 422).
///
/// Three deliberate improvements over the reference, approved by the user
/// (docs/parity/DECISIONS.md D6 and T1):
/// 1. the code is stored as a bcrypt hash, never in plain text — the
/// reference kept it readable in a customer column;
/// 2. it expires (`expiresAt`) — the reference's code stayed valid forever;
/// 3. attempts are counted and the challenge locks out — the reference had
/// no limit at all.
/// The parity behaviour itself is the code comparison and the rejection.
model CustomerOtp {
id String @id @default(cuid())
/// Normalised to English digits before storing, as the reference does with
/// Utility::faTOen() on both send and verify.
mobile String
customerId String
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
/// bcrypt hash of the 4-digit code. Never the code itself.
codeHash String
expiresAt DateTime
/// Failed verify attempts against THIS challenge.
attempts Int @default(0)
/// Set when the code is accepted, so one challenge cannot be reused.
consumedAt DateTime?
/// Kept for the per-IP send throttle.
requestIp String?
createdAt DateTime @default(now())
@@index([mobile, createdAt])
@@index([customerId])
@@index([expiresAt])
}
/// Bespoke migration ledger left by the EARLIER, pre-Prisma port.
///
/// It predates Prisma Migrate in this repo and holds 12 historical rows
/// (`0001_init.sql`, `0002_admin_schema.sql`, `seed:initial`, and the early
/// `2026091…` migrations). It is the only record of how that port was applied,
/// so it is **kept and never written to or deleted from** (DEAD-07).
///
/// It is declared here purely so `prisma migrate diff` stops proposing
/// `DROP TABLE "_goldbank_migrations"` — an undeclared table reads as drift.
/// `@@ignore` keeps it out of Prisma Client: nothing in the application should
/// ever read or write it, and Prisma Migrate will not manage its shape either.
///
/// Prisma's own ledger is `_prisma_migrations`, which is separate and is never
/// edited by hand (D14).
model goldbank_migrations {
name String @id
appliedAt DateTime @default(now()) @map("applied_at") @db.Timestamptz(6)
@@map("_goldbank_migrations")
@@ignore
}