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'], + }, +});