Two independent defects in docker/api.Dockerfile, both found by booting the published image on galactus rather than by reading it. Neither had ever been observed because no deploy had previously got far enough to start the API. 1. "Cannot find module '@jorgecuadros/database'". node-linker=hoisted flattens EXTERNAL dependencies into /repo/node_modules, but the workspace dependency stays linked per-package at apps/api/node_modules/@jorgecuadros/database -> ../../../../packages/database. The runtime stage copied only /repo/node_modules, so the link was dropped. Copy the @jorgecuadros scope dir as well — not the whole directory, whose only other contents are devDependencies. 2. "Prisma Client could not locate the Query Engine for runtime linux-musl-openssl-3.0.x ... generated for linux-musl". Prisma picks its engine by sniffing the build environment. The build stage had no openssl so it generated for plain "linux-musl", while the runtime stage demanded the openssl-3.0.x variant and refused to start. Fixed at both ends: binaryTargets now names the musl target explicitly in schema.prisma, so the shipped engine no longer depends on what happens to be installed at build time, and openssl is installed in the deps stage (generate) and the runtime stage (Prisma needs it regardless). Verified by running the published image on galactus with each fix patched in by hand, against the real database, until it got past both failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
687 lines
23 KiB
Plaintext
687 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"
|
|
// "native" covers local dev. The musl target is declared EXPLICITLY because
|
|
// Prisma picks the engine by sniffing the build environment: the Docker build
|
|
// stage has no openssl, so it detected plain "linux-musl", while the runtime
|
|
// stage (which needs openssl for other reasons) then demanded
|
|
// "linux-musl-openssl-3.0.x" and refused to start. Naming it here makes the
|
|
// engine that ships independent of what happens to be installed at build time.
|
|
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
|
}
|
|
|
|
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")
|
|
}
|