The office keeps more than one operating account (Utilities banks in MXN, Seguros in USD), but bank_transactions was a single implicit MXN register by design. Adds Bank/BankAccount and makes every read and write in the module scoped to exactly one account. Schema: - Bank / BankAccount. Currency is fixed per account and BankTransaction has no currency column of its own — a movement inherits its account's, the way a real bank account doesn't mix currencies. - BankTransaction.bankAccountId, required. A movement with no known account isn't reconcilable against a statement. - @@index([bankAccountId, transactionDate]): every read now filters by account and orders/groups by date. Migration: - backfill_bank_accounts.py seeds Scotiabank + "Utilities — Scotiabank (MXN)" and backfills all 22,669 existing rows onto it, then promotes the column to NOT NULL and attaches the FK. Standalone because prisma db push cannot add a required column to a populated table. Idempotent; re-running once a second account exists does not re-point rows. - run_all.py runs it (both modes) before transform_bank.py, which now resolves the account by label and fails fast if it is missing. API: - ?bankAccountId= required on list/stats/facets/summary — not optional with an "all accounts" default, since summing an MXN and a USD register repeats the currency-collapsing mistake the billing module exists to prevent. Missing is 400, unknown is 404. - facets() had no account clause at all and summary() has two raw-SQL rollups; all three are now parameterised. Scoping only one of summary's queries would leave the year list and its drill-down describing different books. - New bank/accounts + bank/banks sub-resource under a MANAGER bank:manage-accounts ability. currency is absent from the update DTO: booked movements are denominated in it, so editing would re-denominate history. Capture into a closed account is rejected. Web: - /banco gains an account picker (remembered per browser) and reads every figure in the selected account's currency; the "single currency (MXN)" doc-comment and the hardcoded MXN formatting are gone. - New /banco/cuentas for banks and accounts. Accounts are closed, never deleted — the FK is required, so deleting one would destroy its register. - /inicio's chequera card names the account it is reading instead of implying a single register. Verified against dev + browser: a second USD account showed full read/write isolation from the MXN register, whose totals were unchanged (22,669 movements, net 1,014,266.97). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
680 lines
23 KiB
Plaintext
680 lines
23 KiB
Plaintext
// Unified customer / insurance / utilities data model.
|
|
// See C:\Users\ricar\.claude\plans\logical-yawning-tome.md for the migration
|
|
// plan this schema implements (source: UTILITIES.accdb, SEGUROS 16_be.mdb,
|
|
// SCOTHIA.mdb). Every model that originates from a legacy Access table
|
|
// carries legacySource* provenance columns so migrated rows are traceable
|
|
// back to their Access original and the ETL can be re-run idempotently.
|
|
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
output = "../generated/client"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "mysql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
enum Currency {
|
|
USD
|
|
MXN
|
|
}
|
|
|
|
enum TransactionDomain {
|
|
UTILITY
|
|
INSURANCE
|
|
TRUST
|
|
}
|
|
|
|
/// How a ledger row entered the system. Every capture path funnels through
|
|
/// BillingService (single write path, single audit trail); this records which
|
|
/// one, so an auto-captured receipt is auditable without joining the statement
|
|
/// tables. `OCR` is reserved for the statement auto-capture pipeline
|
|
/// (docs/RECEIPT_CAPTURE_SPEC.md §2), which posts through the same batch path
|
|
/// as hand-keyed check batches.
|
|
enum TransactionCaptureSource {
|
|
MANUAL
|
|
BATCH
|
|
OCR
|
|
}
|
|
|
|
enum ServiceKind {
|
|
WATER
|
|
ELECTRIC
|
|
GAS
|
|
CABLE
|
|
PROPERTY_TAX
|
|
FEDERAL_ZONE
|
|
ALARM
|
|
OTHER
|
|
}
|
|
|
|
/// Ordered access tier (rank): ADMIN > MANAGER > STAFF > VIEWER. VIEWER is the
|
|
/// read-only role; STAFF and above can write. Enforced by the API's ability
|
|
/// matrix (apps/api/src/auth/abilities.ts), not by the enum itself.
|
|
enum UserRole {
|
|
ADMIN
|
|
MANAGER
|
|
STAFF
|
|
VIEWER
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Identity — the actual point of the project: one customer record shared by
|
|
// both business lines.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
model Customer {
|
|
id String @id @default(uuid())
|
|
name String
|
|
// Which legacy table `name` actually came from. Null = DATGRAL.NOMBRE, the
|
|
// normal case. Anything else means DATGRAL's name was blank and the name was
|
|
// recovered from a secondary table (see migration/transform_customers.py),
|
|
// so staff can tell a reconstructed name from an original one.
|
|
nameSource String?
|
|
// True when `name` is the "(SIN NOMBRE)" placeholder. Denormalized so lists
|
|
// can sort nameless records last — ordering by `name` alone puts them first,
|
|
// since "(" sorts before every letter.
|
|
nameMissing Boolean @default(false)
|
|
addressLine1 String?
|
|
addressLine2 String?
|
|
city String?
|
|
state String?
|
|
zipCode String?
|
|
country String?
|
|
phone String?
|
|
mobile String?
|
|
fax String?
|
|
email String?
|
|
notes String? @db.Text
|
|
identificationType String?
|
|
identificationNumber String?
|
|
identificationExpiration DateTime?
|
|
customerSince DateTime?
|
|
status Boolean @default(true)
|
|
minimumBalance Decimal? @db.Decimal(12, 2)
|
|
feeAmount Decimal? @db.Decimal(12, 2)
|
|
preferredCurrency Currency @default(USD)
|
|
// Soft-delete marker. Distinct from `status` (a legacy business flag): a
|
|
// non-null archivedAt hides the row from default lists while preserving it
|
|
// and its legacy provenance. Never hard-delete migrated data.
|
|
archivedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
legacyRefs CustomerLegacyRef[]
|
|
properties Property[]
|
|
policies Policy[]
|
|
vehicles Vehicle[]
|
|
transactions Transaction[]
|
|
|
|
@@map("customers")
|
|
}
|
|
|
|
/// Generalizes the old app's customer_mapping table: one row per legacy
|
|
/// record folded into this customer, from either source system.
|
|
model CustomerLegacyRef {
|
|
id String @id @default(uuid())
|
|
customerId String
|
|
customer Customer @relation(fields: [customerId], references: [id])
|
|
sourceSystem String // "utilities" | "insurance"
|
|
sourceTable String // e.g. "DATGRAL", "COBRO3"
|
|
legacyId String // stringified legacy id (source columns are often DOUBLE)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([sourceSystem, sourceTable, legacyId])
|
|
@@map("customer_legacy_refs")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Insurance domain
|
|
// ---------------------------------------------------------------------------
|
|
|
|
model InsuranceProvider {
|
|
id String @id @default(uuid())
|
|
name String @unique
|
|
policies Policy[]
|
|
|
|
@@map("insurance_providers")
|
|
}
|
|
|
|
model PolicyType {
|
|
id String @id @default(uuid())
|
|
name String @unique
|
|
shortDescription String?
|
|
policies Policy[]
|
|
|
|
@@map("policy_types")
|
|
}
|
|
|
|
/// Consolidates INCENDIO/MULT/M EMPR/all auto-table variants/LICENCIAS into
|
|
/// one table with a policyType discriminator, instead of one Access table
|
|
/// per line of business.
|
|
model Policy {
|
|
id String @id @default(uuid())
|
|
policyNumber String
|
|
customerId String
|
|
customer Customer @relation(fields: [customerId], references: [id])
|
|
policyTypeId String?
|
|
policyType PolicyType? @relation(fields: [policyTypeId], references: [id])
|
|
insuranceProviderId String?
|
|
insuranceProvider InsuranceProvider? @relation(fields: [insuranceProviderId], references: [id])
|
|
agentName String?
|
|
policyDate DateTime?
|
|
policyFrom DateTime?
|
|
policyTo DateTime?
|
|
coveragePeriodDays Int? @default(365)
|
|
netPremium Decimal? @db.Decimal(12, 2)
|
|
policyFee Decimal? @db.Decimal(12, 2)
|
|
brokerFee Decimal? @db.Decimal(12, 2)
|
|
commission Decimal? @db.Decimal(12, 2)
|
|
total Decimal? @db.Decimal(12, 2)
|
|
currency Currency @default(MXN)
|
|
observations String? @db.Text
|
|
notes String? @db.Text
|
|
coveragesJson Json?
|
|
endorsement Boolean @default(false)
|
|
liquidated Boolean @default(false)
|
|
liquidationNumber String?
|
|
liquidationDate DateTime?
|
|
// Soft-delete marker (see Customer.archivedAt). Never hard-delete migrated
|
|
// policy data; archiving hides it from default lists.
|
|
archivedAt DateTime?
|
|
legacySourceDb String?
|
|
legacySourceTable String?
|
|
legacyId String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
installments PolicyPaymentInstallment[]
|
|
vehicles Vehicle[]
|
|
insuredDrivers InsuredDriver[]
|
|
beneficiaries PolicyBeneficiary[]
|
|
claims Claim[]
|
|
documents PolicyDocument[]
|
|
properties Property[]
|
|
renewalNotices RenewalNotice[]
|
|
|
|
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
|
@@index([policyNumber])
|
|
@@map("policies")
|
|
}
|
|
|
|
enum RenewalNoticeChannel {
|
|
MAIL
|
|
EMAIL
|
|
}
|
|
|
|
/// Replaces the legacy `CONTROL <ramo> RENEW[2/3] X MES` reports — a
|
|
/// per-batch printed checklist of who'd been sent which reminder. One row
|
|
/// per notice generation actually sent for a policy, so "who got a 1st/2nd/
|
|
/// 3rd notice and when" is a query instead of a paper trail. See
|
|
/// docs/RENEWAL_NOTICES.md for the legacy report chain this replaces.
|
|
model RenewalNotice {
|
|
id String @id @default(uuid())
|
|
policyId String
|
|
policy Policy @relation(fields: [policyId], references: [id])
|
|
// 1 = first notice (bare RENEW), 2 = RENEW2, 3 = RENEW3 in the legacy naming.
|
|
generation Int
|
|
channel RenewalNoticeChannel @default(MAIL)
|
|
sentAt DateTime?
|
|
sentById String?
|
|
notes String? @db.Text
|
|
createdAt DateTime @default(now())
|
|
|
|
// One row per generation per policy — matches the legacy's 1st/2nd/3rd
|
|
// notice cadence; re-running the same generation for a policy updates it
|
|
// rather than duplicating a log entry.
|
|
@@unique([policyId, generation])
|
|
@@map("renewal_notices")
|
|
}
|
|
|
|
/// Unpivots the 4 hardcoded payment-installment columns found on every
|
|
/// legacy policy table (1ER PAGO/FECHA PAGO/NO CHEQUE, ...2, ...3, ...4).
|
|
model PolicyPaymentInstallment {
|
|
id String @id @default(uuid())
|
|
policyId String
|
|
policy Policy @relation(fields: [policyId], references: [id])
|
|
sequence Int
|
|
amount Decimal? @db.Decimal(12, 2)
|
|
currency Currency @default(MXN)
|
|
dueDate DateTime?
|
|
paidDate DateTime?
|
|
checkNumber String?
|
|
isCash Boolean @default(false)
|
|
|
|
@@map("policy_payment_installments")
|
|
}
|
|
|
|
/// Unpivots MCA2's 3 hardcoded vehicle slots (and the single-vehicle auto
|
|
/// tables) into one row per vehicle.
|
|
model Vehicle {
|
|
id String @id @default(uuid())
|
|
customerId String?
|
|
customer Customer? @relation(fields: [customerId], references: [id])
|
|
policyId String?
|
|
policy Policy? @relation(fields: [policyId], references: [id])
|
|
make String?
|
|
model String?
|
|
modelYear String?
|
|
bodyType String?
|
|
engineNumber String?
|
|
licensePlate String?
|
|
vinNumber String?
|
|
stateCode String?
|
|
notes String? @db.Text
|
|
// One legacy policy row can carry up to 3 vehicles, so they share a
|
|
// legacyId (the source row number) — provenance is NOT unique per vehicle.
|
|
// Sync rebuilds legacy vehicles by scoped delete + reinsert instead of upsert.
|
|
legacySourceTable String?
|
|
legacyId String?
|
|
|
|
@@map("vehicles")
|
|
}
|
|
|
|
/// Unpivots the repeated named-insured/license columns in MCA2/LICENCIAS.
|
|
model InsuredDriver {
|
|
id String @id @default(uuid())
|
|
policyId String
|
|
policy Policy @relation(fields: [policyId], references: [id])
|
|
fullName String?
|
|
birthDate DateTime?
|
|
sex String?
|
|
occupation String?
|
|
licenseNumber String?
|
|
licenseState String?
|
|
|
|
@@map("insured_drivers")
|
|
}
|
|
|
|
/// From BENEF — already a clean child table in the source data.
|
|
model PolicyBeneficiary {
|
|
id String @id @default(uuid())
|
|
policyId String
|
|
policy Policy @relation(fields: [policyId], references: [id])
|
|
name String?
|
|
address String?
|
|
phone String?
|
|
email String?
|
|
|
|
@@map("policy_beneficiaries")
|
|
}
|
|
|
|
/// From DATOS (siniestros).
|
|
model Claim {
|
|
id String @id @default(uuid())
|
|
policyId String
|
|
policy Policy @relation(fields: [policyId], references: [id])
|
|
claimType String?
|
|
incidentDate DateTime?
|
|
reportedDate DateTime?
|
|
description String? @db.Text
|
|
adjusterId String?
|
|
adjuster Adjuster? @relation(fields: [adjusterId], references: [id])
|
|
claimedAmount Decimal? @db.Decimal(12, 2)
|
|
settledAmount Decimal? @db.Decimal(12, 2)
|
|
settlementDate DateTime?
|
|
checkNumber String?
|
|
resolved Boolean @default(false)
|
|
resolutionNotes String? @db.Text
|
|
|
|
@@map("claims")
|
|
}
|
|
|
|
/// From AJUSTADORES / AJUSTADORESATLAS.
|
|
model Adjuster {
|
|
id String @id @default(uuid())
|
|
company String?
|
|
city String?
|
|
name String?
|
|
phone String?
|
|
beeper String?
|
|
claims Claim[]
|
|
|
|
@@map("adjusters")
|
|
}
|
|
|
|
/// Extracted LONGBINARY blobs from the policy tables — file lives in object
|
|
/// storage, only the pointer + type lives here.
|
|
model PolicyDocument {
|
|
id String @id @default(uuid())
|
|
policyId String
|
|
policy Policy @relation(fields: [policyId], references: [id])
|
|
documentType String
|
|
storageKey String
|
|
originalColumn String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@map("policy_documents")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Utilities domain
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// From DATMEX — shared with the insurance domain so a property can carry
|
|
/// both a home-insurance policy and utility service enrollments.
|
|
model Property {
|
|
id String @id @default(uuid())
|
|
customerId String
|
|
customer Customer @relation(fields: [customerId], references: [id])
|
|
policyId String?
|
|
policy Policy? @relation(fields: [policyId], references: [id])
|
|
addressLine1 String?
|
|
addressLine2 String?
|
|
phone1 String?
|
|
phone2 String?
|
|
phone3 String?
|
|
zone String?
|
|
// Soft-delete marker (see Customer.archivedAt).
|
|
archivedAt DateTime?
|
|
legacySourceTable String?
|
|
legacyId String?
|
|
createdAt DateTime @default(now())
|
|
|
|
services PropertyService[]
|
|
documents ServiceDocument[]
|
|
trustAccount TrustAccount?
|
|
|
|
@@unique([legacySourceTable, legacyId])
|
|
@@map("properties")
|
|
}
|
|
|
|
/// Unpivots DATMEX's inline service columns and PROFILE's enrollment flags
|
|
/// into one row per enrolled service per property.
|
|
model PropertyService {
|
|
id String @id @default(uuid())
|
|
propertyId String
|
|
property Property @relation(fields: [propertyId], references: [id])
|
|
kind ServiceKind
|
|
accountNumber String?
|
|
meterNumber String?
|
|
route String?
|
|
dueDay String?
|
|
active Boolean @default(true)
|
|
notes String? @db.Text
|
|
|
|
@@map("property_services")
|
|
}
|
|
|
|
model ServiceDocument {
|
|
id String @id @default(uuid())
|
|
propertyId String
|
|
property Property @relation(fields: [propertyId], references: [id])
|
|
documentType String
|
|
storageKey String
|
|
createdAt DateTime @default(now())
|
|
|
|
@@map("service_documents")
|
|
}
|
|
|
|
/// From TRUSTVENCE.
|
|
model TrustAccount {
|
|
id String @id @default(uuid())
|
|
propertyId String @unique
|
|
property Property @relation(fields: [propertyId], references: [id])
|
|
bankName String?
|
|
trustNumber String?
|
|
bankFee Decimal? @db.Decimal(12, 2)
|
|
dueDate1 DateTime?
|
|
dueDate2 DateTime?
|
|
|
|
@@map("trust_accounts")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared financial ledger — one office, one set of books.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// ES/EN transaction-type lookup, carried over from the old schema.
|
|
model TypeTransaction {
|
|
id String @id @default(uuid())
|
|
nameEn String
|
|
nameEs String?
|
|
isService Boolean @default(false)
|
|
transactions Transaction[]
|
|
|
|
@@map("type_transactions")
|
|
}
|
|
|
|
/// Unifies utilities' EFECTIVO/EFECTIVO FM3/EFECTIVO_BACKUP/FEE ANUAL/
|
|
/// datos2/fee15/billing/CHEQUE FM3/IVA 2015 and insurance's EFECTIVO.
|
|
model Transaction {
|
|
id String @id @default(uuid())
|
|
customerId String
|
|
customer Customer @relation(fields: [customerId], references: [id])
|
|
domain TransactionDomain
|
|
typeId String?
|
|
type TypeTransaction? @relation(fields: [typeId], references: [id])
|
|
transactionDate DateTime
|
|
period String?
|
|
reference String?
|
|
amount Decimal @db.Decimal(12, 2)
|
|
currency Currency @default(MXN)
|
|
exchangeRate Decimal? @db.Decimal(10, 4)
|
|
checkNumber String?
|
|
message String? @db.Text
|
|
outstanding Boolean @default(false)
|
|
/// How this row was captured. NULL = migrated from Access (the legacy*
|
|
/// columns below say which table). Set explicitly on everything the app
|
|
/// books, so an OCR-posted receipt is distinguishable from a hand-keyed one
|
|
/// without joining the statement tables.
|
|
captureSource TransactionCaptureSource?
|
|
/// Back-pointer to the artifact that produced this row — a
|
|
/// `StatementDocument.id` for OCR captures (see RECEIPT_CAPTURE_SPEC §2).
|
|
/// Unique among live rows via the app's duplicate guard, not a DB constraint,
|
|
/// because a voided row must not block a corrected re-post of the same
|
|
/// document.
|
|
captureRef String?
|
|
// Append + void: booked rows are never edited or hard-deleted. A non-null
|
|
// voidedAt reverses the movement — it MUST be excluded from every balance
|
|
// and total (SUM/count) so a voided amount stops affecting the books.
|
|
voidedAt DateTime?
|
|
voidedById String?
|
|
legacySourceDb String?
|
|
legacySourceTable String?
|
|
legacyId String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([customerId, transactionDate])
|
|
// By-check reconciliation (billing.byCheck / the cheque-count report) looks
|
|
// rows up by check number alone — the legacy EDITA CHEQUE COUNT lookup.
|
|
@@index([checkNumber])
|
|
// Drives the duplicate-post guard in BillingService.createBatch.
|
|
@@index([captureRef])
|
|
@@unique([legacySourceDb, legacySourceTable, legacyId])
|
|
@@map("transactions")
|
|
}
|
|
|
|
/// From TIPO HIST.
|
|
model ExchangeRate {
|
|
id String @id @default(uuid())
|
|
rate Decimal @db.Decimal(10, 4)
|
|
effectiveDate DateTime
|
|
effectiveHour DateTime?
|
|
|
|
@@map("exchange_rates")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Company bank register (SCOTHIA.mdb) — the office's own operating account,
|
|
// deliberately separate from customer-facing Transaction records.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// From TABLA RAMODOS ("ramo" = line of business).
|
|
model BusinessLineCategory {
|
|
id String @id @default(uuid())
|
|
name String @unique
|
|
bankTransactions BankTransaction[]
|
|
|
|
@@map("business_line_categories")
|
|
}
|
|
|
|
/// The institution a chequera is held at. Purely a grouping label for the
|
|
/// accounts under it — no money hangs off a Bank directly.
|
|
model Bank {
|
|
id String @id @default(uuid())
|
|
name String @unique
|
|
/// "MX" | "US" — informational, used only to label the account picker.
|
|
country String?
|
|
accounts BankAccount[]
|
|
|
|
@@map("banks")
|
|
}
|
|
|
|
/// One physical chequera. Currency is fixed per account, because a real bank
|
|
/// account is: there is deliberately NO currency column on BankTransaction, a
|
|
/// movement inherits its account's. This is what keeps the MXN (Utilities /
|
|
/// Scotiabank) and USD (Seguros) registers from ever being summed together,
|
|
/// the same rule the customer ledger follows per currency.
|
|
model BankAccount {
|
|
id String @id @default(uuid())
|
|
bankId String
|
|
bank Bank @relation(fields: [bankId], references: [id])
|
|
/// Staff-facing name, e.g. "Utilities — Scotiabank (MXN)".
|
|
label String
|
|
currency Currency
|
|
/// Hint only, never enforced — one chequera can pay for more than one line.
|
|
businessLine TransactionDomain?
|
|
active Boolean @default(true)
|
|
movements BankTransaction[]
|
|
|
|
@@map("bank_accounts")
|
|
}
|
|
|
|
/// Unifies SCOTHIA's DATOS E (egresos) / DATOS I (ingresos) into one
|
|
/// signed-amount table: income positive, expense negative.
|
|
model BankTransaction {
|
|
id String @id @default(uuid())
|
|
// Required: a movement with no known account isn't reconcilable against a
|
|
// statement. Every migrated row is SCOTHIA = the Utilities MXN account.
|
|
bankAccountId String
|
|
bankAccount BankAccount @relation(fields: [bankAccountId], references: [id])
|
|
transactionDate DateTime
|
|
transactionType String?
|
|
reference String?
|
|
concept String?
|
|
amount Decimal @db.Decimal(12, 2)
|
|
categoryId String?
|
|
category BusinessLineCategory? @relation(fields: [categoryId], references: [id])
|
|
cleared Boolean @default(false)
|
|
transferred Boolean @default(false)
|
|
notes String? @db.Text
|
|
amountInWords String?
|
|
// Append + void (see Transaction.voidedAt): excluded from income/expense/net.
|
|
voidedAt DateTime?
|
|
voidedById String?
|
|
legacySourceTable String?
|
|
legacyId String?
|
|
|
|
// Provenance stays globally unique: every legacy row belongs to the one
|
|
// Scotiabank account, so adding accounts never collides here.
|
|
@@unique([legacySourceTable, legacyId])
|
|
@@index([bankAccountId, transactionDate])
|
|
@@map("bank_transactions")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin / shared
|
|
// ---------------------------------------------------------------------------
|
|
|
|
model User {
|
|
id String @id @default(uuid())
|
|
name String
|
|
email String @unique
|
|
passwordHash String
|
|
role UserRole @default(STAFF)
|
|
active Boolean @default(true)
|
|
// UI text-size preference, so it follows the person between machines
|
|
// instead of living only in one browser's localStorage. Range is clamped
|
|
// API-side (see UpdatePreferencesDto) to match the web's presets.
|
|
uiScale Float @default(1)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
activityLogs ActivityLog[]
|
|
|
|
@@map("users")
|
|
}
|
|
|
|
model ActivityLog {
|
|
id String @id @default(uuid())
|
|
userId String?
|
|
user User? @relation(fields: [userId], references: [id])
|
|
event String
|
|
level String
|
|
message Json?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@map("activity_logs")
|
|
}
|
|
|
|
model EmailTemplate {
|
|
id String @id @default(uuid())
|
|
name String
|
|
subject String
|
|
templateSource String @db.Text
|
|
|
|
@@map("email_templates")
|
|
}
|
|
|
|
model EmailCampaign {
|
|
id String @id @default(uuid())
|
|
campaignName String
|
|
subject String?
|
|
body String? @db.Text
|
|
status String @default("in_progress")
|
|
emailSentCount Int @default(0)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@map("email_campaigns")
|
|
}
|
|
|
|
model EmailLog {
|
|
id String @id @default(uuid())
|
|
customerId String?
|
|
emailAddress String?
|
|
emailType String?
|
|
requestBody String? @db.Text
|
|
responseBody String? @db.Text
|
|
sentAt DateTime @default(now())
|
|
|
|
@@map("email_log")
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Admin database operations (Operaciones): backup / restore / re-import / sync.
|
|
// Each long-running op is one OpsJob row so the web UI can poll status + tail
|
|
// the captured log. Rows are the audit trail for who ran a destructive op.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
enum OpsJobKind {
|
|
BACKUP
|
|
RESTORE
|
|
REIMPORT
|
|
SYNC
|
|
}
|
|
|
|
enum OpsJobStatus {
|
|
RUNNING
|
|
SUCCESS
|
|
FAILED
|
|
}
|
|
|
|
model OpsJob {
|
|
id String @id @default(uuid())
|
|
kind OpsJobKind
|
|
status OpsJobStatus @default(RUNNING)
|
|
// Combined stdout+stderr of the spawned process, appended as it runs.
|
|
log String @db.LongText
|
|
// Op-specific inputs (e.g. the backup filename a RESTORE targets). No FK on
|
|
// createdById — the actor id is stored flat, like activity_logs' userId use.
|
|
params Json?
|
|
createdById String?
|
|
startedAt DateTime @default(now())
|
|
finishedAt DateTime?
|
|
|
|
@@index([status])
|
|
@@index([startedAt])
|
|
@@map("ops_jobs")
|
|
}
|