feat/catalog-inventory #2
|
|
@ -6,3 +6,4 @@ coverage/
|
|||
!.env.example
|
||||
src/generated/
|
||||
*.log
|
||||
.tmp/
|
||||
|
|
|
|||
|
|
@ -19,11 +19,13 @@
|
|||
"db:validate": "prisma validate",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:deploy": "prisma migrate deploy",
|
||||
"check": "pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
|
||||
"check": "pnpm migrations:check && pnpm format:check && pnpm db:validate && pnpm typecheck && pnpm test:coverage && pnpm build",
|
||||
"start:watch": "node --watch dist/main.js",
|
||||
"db:status": "prisma migrate status",
|
||||
"bootstrap:owner": "node dist/cli/bootstrap-owner.js",
|
||||
"db:test": "node scripts/test-migrations.mjs"
|
||||
"db:test": "node scripts/test-migrations.mjs",
|
||||
"migrations:check": "node scripts/check-migrations.mjs",
|
||||
"security:audit": "pnpm audit --prod --audit-level=high"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.1.0",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'dotenv/config';
|
|||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
schema: 'prisma/schema.prisma',
|
||||
schema: 'prisma',
|
||||
migrations: { path: 'prisma/migrations' },
|
||||
datasource: { url: process.env.DATABASE_URL },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
model Address {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
organizationId String @map("organization_id") @db.Uuid
|
||||
recipient String @db.VarChar(160)
|
||||
line1 String @db.VarChar(200)
|
||||
line2 String @default("") @db.VarChar(200)
|
||||
city String @db.VarChar(100)
|
||||
region String @db.VarChar(100)
|
||||
postalCode String @map("postal_code") @db.VarChar(20)
|
||||
countryCode String @map("country_code") @db.Char(2)
|
||||
phone String @db.VarChar(16)
|
||||
isDefault Boolean @default(false) @map("is_default")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Cascade)
|
||||
@@index([userId, organizationId, createdAt, id])
|
||||
@@map("addresses")
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
enum ProductStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
enum GroupKind {
|
||||
CATEGORY
|
||||
COLLECTION
|
||||
}
|
||||
model Product {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @map("organization_id") @db.Uuid
|
||||
name String @db.VarChar(160)
|
||||
slug String @db.VarChar(160)
|
||||
description String @default("") @db.VarChar(5000)
|
||||
status ProductStatus @default(DRAFT)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
|
||||
variants ProductVariant[]
|
||||
groups ProductGroup[]
|
||||
@@unique([organizationId, slug])
|
||||
@@unique([id, organizationId])
|
||||
@@index([organizationId, status, createdAt, id])
|
||||
@@map("products")
|
||||
}
|
||||
model ProductVariant {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
productId String @map("product_id") @db.Uuid
|
||||
organizationId String @map("organization_id") @db.Uuid
|
||||
sku String @db.VarChar(64)
|
||||
name String @db.VarChar(160)
|
||||
price Decimal @db.Decimal(12,2)
|
||||
currency String @default("INR") @db.Char(3)
|
||||
attributes Json @default("{}")
|
||||
active Boolean @default(true)
|
||||
stockItems StockItem[]
|
||||
product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||
@@unique([organizationId, sku])
|
||||
@@unique([id, organizationId])
|
||||
@@index([productId, active])
|
||||
@@map("product_variants")
|
||||
}
|
||||
model CatalogGroup {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @map("organization_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
slug String @db.VarChar(100)
|
||||
kind GroupKind
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
|
||||
products ProductGroup[]
|
||||
@@unique([organizationId, kind, slug])
|
||||
@@unique([id, organizationId])
|
||||
@@map("catalog_groups")
|
||||
}
|
||||
model ProductGroup {
|
||||
productId String @map("product_id") @db.Uuid
|
||||
groupId String @map("group_id") @db.Uuid
|
||||
organizationId String @map("organization_id") @db.Uuid
|
||||
product Product @relation(fields: [productId, organizationId], references: [id, organizationId], onDelete: Cascade)
|
||||
group CatalogGroup @relation(fields: [groupId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||
@@id([productId, groupId])
|
||||
@@index([groupId, organizationId])
|
||||
@@map("product_groups")
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
enum ReservationStatus {
|
||||
ACTIVE
|
||||
RELEASED
|
||||
COMMITTED
|
||||
}
|
||||
model Warehouse {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @map("organization_id") @db.Uuid
|
||||
name String @db.VarChar(100)
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Restrict)
|
||||
items StockItem[]
|
||||
@@unique([organizationId, name])
|
||||
@@unique([id, organizationId])
|
||||
@@map("warehouses")
|
||||
}
|
||||
model StockItem {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
organizationId String @map("organization_id") @db.Uuid
|
||||
variantId String @map("variant_id") @db.Uuid
|
||||
warehouseId String @map("warehouse_id") @db.Uuid
|
||||
onHand Int @default(0) @map("on_hand")
|
||||
variant ProductVariant @relation(fields: [variantId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||
warehouse Warehouse @relation(fields: [warehouseId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||
entries StockLedger[]
|
||||
reservations StockReservation[]
|
||||
@@unique([variantId, warehouseId])
|
||||
@@unique([id, organizationId])
|
||||
@@index([organizationId, id])
|
||||
@@map("stock_items")
|
||||
}
|
||||
model StockLedger {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
stockItemId String @map("stock_item_id") @db.Uuid
|
||||
organizationId String @map("organization_id") @db.Uuid
|
||||
actorId String @map("actor_id") @db.Uuid
|
||||
actor User @relation(fields: [actorId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||
delta Int
|
||||
reason String @db.VarChar(200)
|
||||
idempotencyKey String @map("idempotency_key") @db.Uuid
|
||||
requestHash String @map("request_hash") @db.Char(64)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
stockItem StockItem @relation(fields: [stockItemId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||
@@unique([organizationId, idempotencyKey])
|
||||
@@index([stockItemId, createdAt, id])
|
||||
@@map("stock_ledger")
|
||||
}
|
||||
model StockReservation {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
stockItemId String @map("stock_item_id") @db.Uuid
|
||||
organizationId String @map("organization_id") @db.Uuid
|
||||
userId String @map("user_id") @db.Uuid
|
||||
user User @relation(fields: [userId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||
quantity Int
|
||||
status ReservationStatus @default(ACTIVE)
|
||||
idempotencyKey String @map("idempotency_key") @db.Uuid
|
||||
requestHash String @map("request_hash") @db.Char(64)
|
||||
expiresAt DateTime @map("expires_at") @db.Timestamptz(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
|
||||
stockItem StockItem @relation(fields: [stockItemId, organizationId], references: [id, organizationId], onDelete: Restrict)
|
||||
@@unique([organizationId, idempotencyKey])
|
||||
@@index([stockItemId, status, expiresAt])
|
||||
@@index([userId, organizationId])
|
||||
@@map("stock_reservations")
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"202609080003_identity_integrity": "872ee89b0b4a45f574d024d37b7f7d2536ce61df19d2ecefe59594edef06fc14",
|
||||
"202609080001_create_organizations": "7ca0a406b175865c54d026df616c82f60c4fb276f37e6dd243602621ff8618f6",
|
||||
"202609080002_identity_access": "e74393574bc5924497a59e71f36ad48310b401268560656102ed24fba84ff902",
|
||||
"20260909141447_catalog_addresses": "c89cc9448494d74f8e5ee0005e81b6265bb01d7f000a81c4af27dc3c9855ba84",
|
||||
"20260909141549_inventory": "ab5b0afde7332dd8de3190781dde95bd941bba91ab525308a7436d108cef7e36",
|
||||
"20260909141622_commerce_integrity": "30545784aa33f35783c0170b80de2bded32be8781e25e027c4e5e2e5da8348ac",
|
||||
"20260909153911_inventory_actor_scope": "7d6d2df3a8229032022f5a2ce43ed37c508c4a71743fd2bc6c24786634611d1e"
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "ProductStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "GroupKind" AS ENUM ('CATEGORY', 'COLLECTION');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "addresses" (
|
||||
"id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"organization_id" UUID NOT NULL,
|
||||
"recipient" VARCHAR(160) NOT NULL,
|
||||
"line1" VARCHAR(200) NOT NULL,
|
||||
"line2" VARCHAR(200) NOT NULL DEFAULT '',
|
||||
"city" VARCHAR(100) NOT NULL,
|
||||
"region" VARCHAR(100) NOT NULL,
|
||||
"postal_code" VARCHAR(20) NOT NULL,
|
||||
"country_code" CHAR(2) NOT NULL,
|
||||
"phone" VARCHAR(16) NOT NULL,
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "addresses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "products" (
|
||||
"id" UUID NOT NULL,
|
||||
"organization_id" UUID NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"slug" VARCHAR(160) NOT NULL,
|
||||
"description" VARCHAR(5000) NOT NULL DEFAULT '',
|
||||
"status" "ProductStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_variants" (
|
||||
"id" UUID NOT NULL,
|
||||
"product_id" UUID NOT NULL,
|
||||
"organization_id" UUID NOT NULL,
|
||||
"sku" VARCHAR(64) NOT NULL,
|
||||
"name" VARCHAR(160) NOT NULL,
|
||||
"price" DECIMAL(12,2) NOT NULL,
|
||||
"currency" CHAR(3) NOT NULL DEFAULT 'INR',
|
||||
"attributes" JSONB NOT NULL DEFAULT '{}',
|
||||
"active" BOOLEAN NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT "product_variants_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "catalog_groups" (
|
||||
"id" UUID NOT NULL,
|
||||
"organization_id" UUID NOT NULL,
|
||||
"name" VARCHAR(100) NOT NULL,
|
||||
"slug" VARCHAR(100) NOT NULL,
|
||||
"kind" "GroupKind" NOT NULL,
|
||||
|
||||
CONSTRAINT "catalog_groups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "product_groups" (
|
||||
"product_id" UUID NOT NULL,
|
||||
"group_id" UUID NOT NULL,
|
||||
"organization_id" UUID NOT NULL,
|
||||
|
||||
CONSTRAINT "product_groups_pkey" PRIMARY KEY ("product_id","group_id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "addresses_user_id_organization_id_created_at_id_idx" ON "addresses"("user_id", "organization_id", "created_at", "id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "products_organization_id_status_created_at_id_idx" ON "products"("organization_id", "status", "created_at", "id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "products_organization_id_slug_key" ON "products"("organization_id", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "products_id_organization_id_key" ON "products"("id", "organization_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_variants_product_id_active_idx" ON "product_variants"("product_id", "active");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_variants_organization_id_sku_key" ON "product_variants"("organization_id", "sku");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "product_variants_id_organization_id_key" ON "product_variants"("id", "organization_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "catalog_groups_organization_id_kind_slug_key" ON "catalog_groups"("organization_id", "kind", "slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "catalog_groups_id_organization_id_key" ON "catalog_groups"("id", "organization_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "product_groups_group_id_organization_id_idx" ON "product_groups"("group_id", "organization_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "addresses" ADD CONSTRAINT "addresses_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "products" ADD CONSTRAINT "products_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_variants" ADD CONSTRAINT "product_variants_product_id_organization_id_fkey" FOREIGN KEY ("product_id", "organization_id") REFERENCES "products"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "catalog_groups" ADD CONSTRAINT "catalog_groups_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_groups" ADD CONSTRAINT "product_groups_product_id_organization_id_fkey" FOREIGN KEY ("product_id", "organization_id") REFERENCES "products"("id", "organization_id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "product_groups" ADD CONSTRAINT "product_groups_group_id_organization_id_fkey" FOREIGN KEY ("group_id", "organization_id") REFERENCES "catalog_groups"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
-- CreateEnum
|
||||
CREATE TYPE "ReservationStatus" AS ENUM ('ACTIVE', 'RELEASED', 'COMMITTED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "warehouses" (
|
||||
"id" UUID NOT NULL,
|
||||
"organization_id" UUID NOT NULL,
|
||||
"name" VARCHAR(100) NOT NULL,
|
||||
|
||||
CONSTRAINT "warehouses_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "stock_items" (
|
||||
"id" UUID NOT NULL,
|
||||
"organization_id" UUID NOT NULL,
|
||||
"variant_id" UUID NOT NULL,
|
||||
"warehouse_id" UUID NOT NULL,
|
||||
"on_hand" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "stock_items_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "stock_ledger" (
|
||||
"id" UUID NOT NULL,
|
||||
"stock_item_id" UUID NOT NULL,
|
||||
"organization_id" UUID NOT NULL,
|
||||
"actor_id" UUID NOT NULL,
|
||||
"delta" INTEGER NOT NULL,
|
||||
"reason" VARCHAR(200) NOT NULL,
|
||||
"idempotency_key" UUID NOT NULL,
|
||||
"request_hash" CHAR(64) NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "stock_ledger_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "stock_reservations" (
|
||||
"id" UUID NOT NULL,
|
||||
"stock_item_id" UUID NOT NULL,
|
||||
"organization_id" UUID NOT NULL,
|
||||
"user_id" UUID NOT NULL,
|
||||
"quantity" INTEGER NOT NULL,
|
||||
"status" "ReservationStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"idempotency_key" UUID NOT NULL,
|
||||
"request_hash" CHAR(64) NOT NULL,
|
||||
"expires_at" TIMESTAMPTZ(3) NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "stock_reservations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "warehouses_organization_id_name_key" ON "warehouses"("organization_id", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "warehouses_id_organization_id_key" ON "warehouses"("id", "organization_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stock_items_organization_id_id_idx" ON "stock_items"("organization_id", "id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "stock_items_variant_id_warehouse_id_key" ON "stock_items"("variant_id", "warehouse_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "stock_items_id_organization_id_key" ON "stock_items"("id", "organization_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stock_ledger_stock_item_id_created_at_id_idx" ON "stock_ledger"("stock_item_id", "created_at", "id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "stock_ledger_organization_id_idempotency_key_key" ON "stock_ledger"("organization_id", "idempotency_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stock_reservations_stock_item_id_status_expires_at_idx" ON "stock_reservations"("stock_item_id", "status", "expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "stock_reservations_user_id_organization_id_idx" ON "stock_reservations"("user_id", "organization_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "stock_reservations_organization_id_idempotency_key_key" ON "stock_reservations"("organization_id", "idempotency_key");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "warehouses" ADD CONSTRAINT "warehouses_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stock_items" ADD CONSTRAINT "stock_items_variant_id_organization_id_fkey" FOREIGN KEY ("variant_id", "organization_id") REFERENCES "product_variants"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stock_items" ADD CONSTRAINT "stock_items_warehouse_id_organization_id_fkey" FOREIGN KEY ("warehouse_id", "organization_id") REFERENCES "warehouses"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stock_ledger" ADD CONSTRAINT "stock_ledger_stock_item_id_organization_id_fkey" FOREIGN KEY ("stock_item_id", "organization_id") REFERENCES "stock_items"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_stock_item_id_organization_id_fkey" FOREIGN KEY ("stock_item_id", "organization_id") REFERENCES "stock_items"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
-- Positive prices, valid stock arithmetic and private-address defaults are
|
||||
-- enforced in PostgreSQL in addition to request validation.
|
||||
ALTER TABLE product_variants ADD CONSTRAINT variant_positive_price CHECK (price > 0);
|
||||
ALTER TABLE stock_items ADD CONSTRAINT stock_nonnegative CHECK (on_hand >= 0);
|
||||
ALTER TABLE stock_ledger ADD CONSTRAINT ledger_nonzero_delta CHECK (delta <> 0);
|
||||
ALTER TABLE stock_reservations ADD CONSTRAINT reservation_positive_quantity CHECK (quantity > 0);
|
||||
CREATE UNIQUE INDEX addresses_one_default ON addresses (user_id) WHERE is_default = true;
|
||||
|
||||
CREATE FUNCTION reject_stock_ledger_mutation() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION 'Stock ledger entries are append-only';
|
||||
END;
|
||||
$$;
|
||||
CREATE TRIGGER stock_ledger_append_only
|
||||
BEFORE UPDATE OR DELETE ON stock_ledger
|
||||
FOR EACH ROW EXECUTE FUNCTION reject_stock_ledger_mutation();
|
||||
|
||||
-- Existing system-owner roles need the new permissions. Custom roles retain
|
||||
-- their explicit grants and must be updated through the authorized API.
|
||||
UPDATE roles SET permissions = ARRAY(
|
||||
SELECT DISTINCT permission FROM unnest(permissions || ARRAY[
|
||||
'catalog.read', 'catalog.manage', 'catalog.publish',
|
||||
'inventory.read', 'inventory.manage', 'inventory.adjust',
|
||||
'inventory.reserve', 'inventory.commit'
|
||||
]::text[]) AS permission ORDER BY permission
|
||||
) WHERE is_system = true;
|
||||
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
-- AddForeignKey
|
||||
ALTER TABLE "stock_ledger" ADD CONSTRAINT "stock_ledger_actor_id_organization_id_fkey" FOREIGN KEY ("actor_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "stock_reservations" ADD CONSTRAINT "stock_reservations_user_id_organization_id_fkey" FOREIGN KEY ("user_id", "organization_id") REFERENCES "users"("id", "organization_id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
|
@ -19,6 +19,9 @@ model Organization {
|
|||
users User[]
|
||||
roles Role[]
|
||||
auditEvents AuditEvent[]
|
||||
products Product[]
|
||||
catalogGroups CatalogGroup[]
|
||||
warehouses Warehouse[]
|
||||
@@map("organizations")
|
||||
}
|
||||
model User {
|
||||
|
|
@ -35,6 +38,9 @@ model User {
|
|||
roles UserRole[]
|
||||
sessions Session[]
|
||||
recoveryTokens RecoveryToken[]
|
||||
addresses Address[]
|
||||
stockEntries StockLedger[]
|
||||
stockReservations StockReservation[]
|
||||
@@unique([organizationId, email])
|
||||
@@unique([id, organizationId])
|
||||
@@index([organizationId, createdAt, id])
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import { readFile, readdir, writeFile } from 'node:fs/promises';
|
||||
import { resolve, join } from 'node:path';
|
||||
|
||||
const root = resolve(
|
||||
process.argv.find((value) => value.startsWith('--root='))?.slice(7) ?? '.',
|
||||
);
|
||||
const folder = join(root, 'prisma', 'migrations');
|
||||
const manifest = join(root, 'prisma', 'migration-checksums.json');
|
||||
const hashes = JSON.parse(await readFile(manifest, 'utf8'));
|
||||
const canonicalHash = (sql) =>
|
||||
createHash('sha256').update(sql.replaceAll('\r\n', '\n')).digest('hex');
|
||||
|
||||
for (const [name, expected] of Object.entries(hashes)) {
|
||||
const actual = canonicalHash(
|
||||
await readFile(join(folder, name, 'migration.sql'), 'utf8'),
|
||||
);
|
||||
if (actual !== expected)
|
||||
throw new Error(`Immutable migration changed: ${name}`);
|
||||
}
|
||||
const names = (await readdir(folder, { withFileTypes: true }))
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
const stamps = new Set();
|
||||
for (const name of names) {
|
||||
const stamp = name.split('_')[0];
|
||||
if (stamps.has(stamp))
|
||||
throw new Error(`Duplicate migration timestamp: ${stamp}`);
|
||||
stamps.add(stamp);
|
||||
if (hashes[name]) continue;
|
||||
if (!/^\d{14}_[a-z0-9_]+$/.test(name))
|
||||
throw new Error(`New migration requires YYYYMMDDHHmmss timestamp: ${name}`);
|
||||
if (!process.argv.includes('--record-new'))
|
||||
throw new Error(`New migration checksum not recorded: ${name}`);
|
||||
hashes[name] = canonicalHash(
|
||||
await readFile(join(folder, name, 'migration.sql'), 'utf8'),
|
||||
);
|
||||
}
|
||||
if (process.argv.includes('--record-new'))
|
||||
await writeFile(manifest, JSON.stringify(hashes, null, 2) + '\n');
|
||||
console.log(`Verified ${Object.keys(hashes).length} immutable migrations`);
|
||||
|
|
@ -10,7 +10,7 @@ const commands = [
|
|||
'diff',
|
||||
'--from-config-datasource',
|
||||
'--to-schema',
|
||||
'prisma/schema.prisma',
|
||||
'prisma',
|
||||
'--exit-code',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import {
|
||||
mkdtempSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
rmdirSync,
|
||||
} from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
describe('migration history protection', () => {
|
||||
it('detects edits to a recorded migration', () => {
|
||||
mkdirSync('.tmp', { recursive: true });
|
||||
const root = mkdtempSync(resolve('.tmp/migration-policy-'));
|
||||
const name = '20260909000000_example';
|
||||
const directory = join(root, 'prisma', 'migrations', name);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const file = join(directory, 'migration.sql');
|
||||
const manifest = join(root, 'prisma', 'migration-checksums.json');
|
||||
const sql = 'SELECT 1;\n';
|
||||
writeFileSync(file, sql);
|
||||
writeFileSync(
|
||||
manifest,
|
||||
JSON.stringify({
|
||||
[name]: createHash('sha256').update(sql).digest('hex'),
|
||||
}),
|
||||
);
|
||||
const run = () =>
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
['scripts/check-migrations.mjs', '--root=' + root],
|
||||
{ stdio: 'pipe' },
|
||||
);
|
||||
try {
|
||||
expect(run().toString()).toContain('Verified 1');
|
||||
writeFileSync(file, 'SELECT 2;\n');
|
||||
expect(run).toThrow();
|
||||
} finally {
|
||||
unlinkSync(file);
|
||||
unlinkSync(manifest);
|
||||
rmdirSync(directory);
|
||||
rmdirSync(join(root, 'prisma', 'migrations'));
|
||||
rmdirSync(join(root, 'prisma'));
|
||||
rmdirSync(root);
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue