From 8f377a1fe9b847dcc7d63f06a374b237bcb39d20 Mon Sep 17 00:00:00 2001 From: MastersGame Date: Thu, 5 Nov 2026 23:00:46 +0100 Subject: [PATCH] feat(db): add Prisma schema and global PrismaModule Models mirror Hiboutik entities (categories tree, brands, suppliers, products and their versions, customers) plus addresses and orders for the upcoming sync. Every synced model is matched with Hiboutik through a unique hiboutikId. The generated client is ignored by git and env files other than .env.example are ignored. --- .gitignore | 9 +- prisma/customObjects/prisma.custom.object.ts | 4 + prisma/migrations/migration_lock.toml | 3 + prisma/prisma.module.ts | 9 + prisma/prisma.service.ts | 20 ++ prisma/schema.prisma | 216 +++++++++++++++++++ prisma7.config.ts | 14 ++ 7 files changed, 271 insertions(+), 4 deletions(-) create mode 100644 prisma/customObjects/prisma.custom.object.ts create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/prisma.module.ts create mode 100644 prisma/prisma.service.ts create mode 100644 prisma/schema.prisma create mode 100644 prisma7.config.ts diff --git a/.gitignore b/.gitignore index 4b56acf..7e0eb9f 100644 --- a/.gitignore +++ b/.gitignore @@ -37,10 +37,8 @@ lerna-debug.log* # dotenv environment variable files .env -.env.development.local -.env.test.local -.env.production.local -.env.local +.env.* +!.env.example # temp directory .temp @@ -54,3 +52,6 @@ pids # Diagnostic reports (https://nodejs.org/api/report.html) report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Prisma client (regenerated by `prisma generate`, run on npm install) +/prisma/generated/ diff --git a/prisma/customObjects/prisma.custom.object.ts b/prisma/customObjects/prisma.custom.object.ts new file mode 100644 index 0000000..2574315 --- /dev/null +++ b/prisma/customObjects/prisma.custom.object.ts @@ -0,0 +1,4 @@ +import { Prisma } from '../generated/client'; + +/** Product loaded with its versions (`include: { versions: true }`). */ +export type ProductWithVersions = Prisma.ProductGetPayload<{ include: { versions: true } }>; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/prisma.module.ts b/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/prisma/prisma.service.ts b/prisma/prisma.service.ts new file mode 100644 index 0000000..347e9d8 --- /dev/null +++ b/prisma/prisma.service.ts @@ -0,0 +1,20 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from './generated/client'; +import { PrismaPg } from '@prisma/adapter-pg'; + +/** Prisma client shared by the whole app (global PrismaModule), connected to `DATABASE_URL` through the pg driver adapter. */ +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + constructor() { + const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL }); + super({ adapter }); + } + + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..a682abd --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,216 @@ +// 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? + /// Kept when removed in Hiboutik (an empty Hiboutik email does not erase it) + 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 (sync not implemented yet). +model CustomerAddress { + id String @id @default(uuid()) + updatedAt DateTime @updatedAt + + address String + city String + country String + hiboutikId Int @unique + isDefault Boolean @default(false) + 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") +} diff --git a/prisma7.config.ts b/prisma7.config.ts new file mode 100644 index 0000000..20471ec --- /dev/null +++ b/prisma7.config.ts @@ -0,0 +1,14 @@ +// This file was generated by Prisma, and assumes you have installed the following: +// npm install --save-dev prisma dotenv +import 'dotenv/config'; +import { defineConfig } from 'prisma/config'; + +export default defineConfig({ + schema: 'prisma/schema.prisma', + migrations: { + path: 'prisma/migrations', + }, + datasource: { + url: process.env['DATABASE_URL'], + }, +});