- Rate limiting counters live in PostgreSQL (throttler_records), shared by every instance, one atomic upsert per request; fails open. - On shutdown, new syncs are refused (503) and the running one is waited for up to SYNC_SHUTDOWN_TIMEOUT; Prisma disconnects last. - Hiboutik calls never follow redirects and are capped at 50 MB. - Prisma errors no longer print query arguments (errorFormat minimal). - In production, DATABASE_URL must require TLS. - The sync lock connection times out after 10 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
246 lines
7.3 KiB
Plaintext
246 lines
7.3 KiB
Plaintext
// This is your Prisma schema file,
|
|
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
|
|
|
// Get a free hosted Postgres database in seconds: `npx create-db`
|
|
|
|
generator client {
|
|
provider = "prisma-client"
|
|
output = "./generated/"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
}
|
|
|
|
/// Product category, mirrored from Hiboutik. Categories form a tree (parent / children).
|
|
model Category {
|
|
id String @id @default(uuid())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// Enabled in the Hiboutik POS (category_enabled)
|
|
enabled Boolean @default(true)
|
|
hiboutikId Int @unique
|
|
name String
|
|
/// Display order among its siblings (category_position)
|
|
position Int @default(0)
|
|
/// Displayed on the website (category_enabled_www)
|
|
webEnabled Boolean @default(true)
|
|
|
|
/// Null for a root category
|
|
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onDelete: SetNull)
|
|
parentId String?
|
|
children Category[] @relation("CategoryTree")
|
|
|
|
products Product[]
|
|
|
|
@@map("categories")
|
|
}
|
|
|
|
/// Product brand, mirrored from Hiboutik.
|
|
model Brand {
|
|
id String @id @default(uuid())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
enabled Boolean @default(true)
|
|
hiboutikId Int @unique
|
|
name String
|
|
|
|
products Product[]
|
|
|
|
@@map("brands")
|
|
}
|
|
|
|
/// Product supplier, mirrored from Hiboutik.
|
|
model Supplier {
|
|
id String @id @default(uuid())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
enabled Boolean @default(true)
|
|
hiboutikId Int @unique
|
|
name String
|
|
|
|
products Product[]
|
|
|
|
@@map("suppliers")
|
|
}
|
|
|
|
/// Product mirrored from Hiboutik. What is actually sold is one of its versions (sizes).
|
|
model Product {
|
|
id String @id @default(uuid())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// Archived in Hiboutik (product_arch)
|
|
archived Boolean @default(false)
|
|
barcode String?
|
|
description String
|
|
/// Null when the product has no discount
|
|
discountPrice Decimal? @db.Decimal(10, 2)
|
|
/// Displayed on the website (product_display_www)
|
|
enabled Boolean @default(true)
|
|
/// Sum of the available stock of all its versions
|
|
globalStock Int @default(0)
|
|
hiboutikId Int @unique
|
|
/// Last update in Hiboutik
|
|
hiboutikUpdatedAt DateTime?
|
|
name String
|
|
price Decimal @db.Decimal(10, 2)
|
|
|
|
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
|
brandId String?
|
|
category Category? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
|
categoryId String?
|
|
supplier Supplier? @relation(fields: [supplierId], references: [id], onDelete: SetNull)
|
|
supplierId String?
|
|
|
|
versions ProductVersion[]
|
|
|
|
@@map("products")
|
|
}
|
|
|
|
/// Sellable version of a product: one per Hiboutik size, or a single one (hiboutikSizeId 0) for a product without sizes.
|
|
model ProductVersion {
|
|
id String @id @default(uuid())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
/// Not unique: Hiboutik is the source of truth and may hold the same barcode on several products
|
|
barcode String?
|
|
/// Size-specific discount, null when none
|
|
discountPrice Decimal? @db.Decimal(10, 2)
|
|
/// Hiboutik size id, 0 for a product without sizes
|
|
hiboutikSizeId Int
|
|
/// Size-specific price, null to use the product price (copy of the product price for a product without sizes)
|
|
price Decimal? @db.Decimal(10, 2)
|
|
stock Int @default(0)
|
|
/// Size name, or the product name for a product without sizes
|
|
versionName String
|
|
|
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade, onUpdate: Restrict)
|
|
productId String
|
|
|
|
orderLines OrderLine[]
|
|
|
|
@@unique([productId, hiboutikSizeId])
|
|
@@index([barcode])
|
|
@@map("productVersions")
|
|
}
|
|
|
|
/// Customer mirrored from Hiboutik.
|
|
model Customer {
|
|
id String @id @default(uuid())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
dateOfBirth DateTime?
|
|
/// Null when the customer has no email in Hiboutik (erased when removed there)
|
|
email String?
|
|
firstName String
|
|
hiboutikId Int @unique
|
|
/// Last update in Hiboutik
|
|
hiboutikUpdatedAt DateTime?
|
|
lastName String
|
|
phone String?
|
|
|
|
addresses CustomerAddress[]
|
|
orders Order[]
|
|
|
|
@@index([email])
|
|
@@map("customers")
|
|
}
|
|
|
|
/// Address of a customer, mirrored from Hiboutik. Its recipient (name, company, phone) can differ from the customer.
|
|
model CustomerAddress {
|
|
id String @id @default(uuid())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
address String
|
|
city String
|
|
company String?
|
|
/// Company registration number (SIRET...), only for an address with a company
|
|
companyNumber String?
|
|
country String
|
|
firstName String
|
|
hiboutikId Int @unique
|
|
isDefault Boolean @default(false)
|
|
lastName String
|
|
/// Company legal status (SAS, SARL...), only for an address with a company
|
|
legalStatus String?
|
|
/// Free complement (building, floor, door code...)
|
|
other String?
|
|
phone String?
|
|
state String?
|
|
/// Company VAT number, only for an address with a company
|
|
taxNumber String?
|
|
zipCode String
|
|
|
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
|
customerId String
|
|
|
|
@@map("customer_addresses")
|
|
}
|
|
|
|
/// Where an order was placed.
|
|
enum OrderSource {
|
|
STORE // hiboutik
|
|
WEB // site
|
|
}
|
|
|
|
/// Order placed in store (Hiboutik sale) or on the website.
|
|
model Order {
|
|
id String @id @default(uuid())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
createdAt DateTime @default(now())
|
|
/// Null for a website order not (yet) sent to Hiboutik
|
|
hiboutikSaleId Int? @unique
|
|
orderedAt DateTime
|
|
source OrderSource
|
|
status String
|
|
total Decimal @db.Decimal(10, 2)
|
|
|
|
/// Null for an anonymous sale or a deleted customer
|
|
customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull)
|
|
customerId String?
|
|
|
|
lines OrderLine[]
|
|
|
|
@@map("orders")
|
|
}
|
|
|
|
/// Line of an order.
|
|
model OrderLine {
|
|
id String @id @default(uuid())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// Snapshot of the product at order time: the catalog can change or be deleted afterwards
|
|
barcode String?
|
|
hiboutikProductId Int
|
|
hiboutikSizeId Int
|
|
productName String
|
|
quantity Int
|
|
unitPrice Decimal @db.Decimal(10, 2)
|
|
versionName String
|
|
|
|
order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
|
|
orderId String
|
|
// Link to the current catalog, set to null when the version is deleted
|
|
productVersion ProductVersion? @relation(fields: [productVersionId], references: [id], onDelete: SetNull)
|
|
productVersionId String?
|
|
|
|
@@map("order_lines")
|
|
}
|
|
|
|
/// Rate limiting counter shared by every instance (ThrottlerDatabaseStorage): one fixed window per throttler key
|
|
/// (route + client IP). Not synced from Hiboutik. Rows whose window and block are over are purged regularly.
|
|
model ThrottlerRecord {
|
|
/// Key generated by the ThrottlerGuard (hash of route, throttler name and client IP)
|
|
key String @id
|
|
/// Requests counted in the current window
|
|
hits Int
|
|
/// End of the current window
|
|
expiresAt DateTime @db.Timestamptz(3)
|
|
/// End of the block once the limit was exceeded, null when not blocked
|
|
blockedUntil DateTime? @db.Timestamptz(3)
|
|
|
|
@@index([expiresAt])
|
|
@@map("throttler_records")
|
|
}
|