From 272827469826a5d3aaf373486d740413fb364f1a Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 25 Mar 2026 11:29:10 -0700 Subject: [PATCH 01/12] (SP:1)[SHOP] add multi-image product model with primary image ordering --- frontend/db/queries/shop/products.ts | 51 +- frontend/db/schema/shop.ts | 37 + frontend/drizzle/0033_marvelous_arclight.sql | 114 + frontend/drizzle/meta/0033_snapshot.json | 6915 +++++++++++++++++ frontend/drizzle/meta/_journal.json | 9 +- .../lib/services/products/admin/queries.ts | 6 +- frontend/lib/services/products/images.ts | 146 + frontend/lib/services/products/mapping.ts | 36 +- .../lib/services/products/mutations/create.ts | 140 +- .../lib/services/products/mutations/delete.ts | 77 +- .../lib/services/products/mutations/toggle.ts | 2 +- .../lib/services/products/mutations/update.ts | 135 +- frontend/lib/shop/data.ts | 20 +- .../shop/product-images-contract.test.ts | 314 + frontend/lib/types/shop.ts | 2 + frontend/lib/validation/shop.ts | 35 + 16 files changed, 7883 insertions(+), 156 deletions(-) create mode 100644 frontend/drizzle/0033_marvelous_arclight.sql create mode 100644 frontend/drizzle/meta/0033_snapshot.json create mode 100644 frontend/lib/services/products/images.ts create mode 100644 frontend/lib/tests/shop/product-images-contract.test.ts diff --git a/frontend/db/queries/shop/products.ts b/frontend/db/queries/shop/products.ts index 93de8dee..b0a0d2f4 100644 --- a/frontend/db/queries/shop/products.ts +++ b/frontend/db/queries/shop/products.ts @@ -13,6 +13,10 @@ import { import { db } from '@/db'; import { productPrices, products } from '@/db/schema'; import type { CatalogSort } from '@/lib/config/catalog'; +import { + getProductImagesByProductIds, + resolveProductImages, +} from '@/lib/services/products/images'; import type { CurrencyCode } from '@/lib/shop/currency'; import { type DbProduct, dbProductSchema } from '@/lib/validation/shop'; @@ -118,14 +122,41 @@ type PublicProductRow = Pick< currency: CurrencyCode; }; -function mapRowToDbProduct(row: PublicProductRow): DbProduct { +function mapRowToDbProduct( + row: PublicProductRow, + imagesByProductId?: Map< + string, + ReturnType['images'] + > +): DbProduct { + const resolvedImages = resolveProductImages( + row, + imagesByProductId?.get(row.id) + ); + return dbProductSchema.parse({ ...row, + imageUrl: resolvedImages.imageUrl, + imagePublicId: resolvedImages.imagePublicId, + images: resolvedImages.images, + primaryImage: resolvedImages.primaryImage, colors: row.colors ?? [], sizes: row.sizes ?? [], }); } +async function mapRowsToDbProducts( + rows: PublicProductRow[] +): Promise { + if (rows.length === 0) return []; + + const imagesByProductId = await getProductImagesByProductIds( + rows.map(row => row.id) + ); + + return rows.map(row => mapRowToDbProduct(row, imagesByProductId)); +} + function priceJoin(currency: CurrencyCode) { return and( eq(productPrices.productId, products.id), @@ -197,7 +228,7 @@ export async function getActiveProducts( .innerJoin(productPrices, priceJoin(currency)) .where(eq(products.isActive, true)); - return rows.map(mapRowToDbProduct); + return await mapRowsToDbProducts(rows); } export async function getActiveProductsPage(options: { @@ -231,7 +262,7 @@ export async function getActiveProductsPage(options: { .limit(options.limit) .offset(options.offset); - return { items: rows.map(mapRowToDbProduct), total: totalCount }; + return { items: await mapRowsToDbProducts(rows), total: totalCount }; } export async function getProductBySlug( @@ -246,7 +277,10 @@ export async function getProductBySlug( .limit(1); const row = rows[0]; - return row ? mapRowToDbProduct(row) : null; + if (!row) return null; + + const imagesByProductId = await getProductImagesByProductIds([row.id]); + return mapRowToDbProduct(row, imagesByProductId); } export async function getPublicProductBySlug( @@ -261,7 +295,10 @@ export async function getPublicProductBySlug( .limit(1); const row = rows[0]; - return row ? mapRowToDbProduct(row) : null; + if (!row) return null; + + const imagesByProductId = await getProductImagesByProductIds([row.id]); + return mapRowToDbProduct(row, imagesByProductId); } export async function getFeaturedProducts( @@ -276,7 +313,7 @@ export async function getFeaturedProducts( .orderBy(desc(products.createdAt)) .limit(limit); - return rows.map(mapRowToDbProduct); + return await mapRowsToDbProducts(rows); } export async function getActiveProductsByIds( @@ -291,5 +328,5 @@ export async function getActiveProductsByIds( .innerJoin(productPrices, priceJoin(currency)) .where(and(eq(products.isActive, true), inArray(products.id, ids))); - return rows.map(mapRowToDbProduct); + return await mapRowsToDbProducts(rows); } diff --git a/frontend/db/schema/shop.ts b/frontend/db/schema/shop.ts index ef730077..f60528fd 100644 --- a/frontend/db/schema/shop.ts +++ b/frontend/db/schema/shop.ts @@ -170,6 +170,43 @@ export const products = pgTable( ] ); +export const productImages = pgTable( + 'product_images', + { + id: uuid('id').defaultRandom().primaryKey(), + productId: uuid('product_id') + .notNull() + .references(() => products.id, { onDelete: 'cascade' }), + imageUrl: text('image_url').notNull(), + imagePublicId: text('image_public_id'), + sortOrder: integer('sort_order').notNull().default(0), + isPrimary: boolean('is_primary').notNull().default(false), + createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { mode: 'date' }) + .defaultNow() + .notNull() + .$onUpdate(() => new Date()), + }, + table => [ + index('product_images_product_id_idx').on(table.productId), + uniqueIndex('product_images_product_sort_order_uq').on( + table.productId, + table.sortOrder + ), + uniqueIndex('product_images_one_primary_per_product_uq') + .on(table.productId) + .where(sql`${table.isPrimary}`), + check( + 'product_images_sort_order_non_negative', + sql`${table.sortOrder} >= 0` + ), + check( + 'product_images_image_url_non_blank', + sql`length(btrim(${table.imageUrl})) > 0` + ), + ] +); + export const orders = pgTable( 'orders', { diff --git a/frontend/drizzle/0033_marvelous_arclight.sql b/frontend/drizzle/0033_marvelous_arclight.sql new file mode 100644 index 00000000..fe265bdb --- /dev/null +++ b/frontend/drizzle/0033_marvelous_arclight.sql @@ -0,0 +1,114 @@ +CREATE TABLE "product_images" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "product_id" uuid NOT NULL, + "image_url" text NOT NULL, + "image_public_id" text, + "sort_order" integer DEFAULT 0 NOT NULL, + "is_primary" boolean DEFAULT false NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "product_images_sort_order_non_negative" CHECK ("product_images"."sort_order" >= 0), + CONSTRAINT "product_images_image_url_non_blank" CHECK (length(btrim("product_images"."image_url")) > 0) +); +--> statement-breakpoint +ALTER TABLE "product_images" ADD CONSTRAINT "product_images_product_id_products_id_fk" FOREIGN KEY ("product_id") REFERENCES "public"."products"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "product_images_product_id_idx" ON "product_images" USING btree ("product_id");--> statement-breakpoint +CREATE UNIQUE INDEX "product_images_product_sort_order_uq" ON "product_images" USING btree ("product_id","sort_order");--> statement-breakpoint +CREATE UNIQUE INDEX "product_images_one_primary_per_product_uq" ON "product_images" USING btree ("product_id") WHERE "product_images"."is_primary";--> statement-breakpoint + +INSERT INTO "product_images" ( + "product_id", + "image_url", + "image_public_id", + "sort_order", + "is_primary", + "created_at", + "updated_at" +) +SELECT + "p"."id", + "p"."image_url", + "p"."image_public_id", + 0, + true, + "p"."created_at", + "p"."updated_at" +FROM "products" "p" +WHERE length(btrim(coalesce("p"."image_url", ''))) > 0;--> statement-breakpoint + +CREATE OR REPLACE FUNCTION shop_product_images_primary_guardrail() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + affected_product_id uuid; + image_count integer; + primary_count integer; +BEGIN + affected_product_id := coalesce(NEW.product_id, OLD.product_id); + + SELECT + count(*)::integer, + count(*) FILTER (WHERE is_primary)::integer + INTO image_count, primary_count + FROM product_images + WHERE product_id = affected_product_id; + + IF image_count > 0 AND primary_count <> 1 THEN + RAISE EXCEPTION + USING errcode = '23514', + constraint = 'product_images_exactly_one_primary_chk', + message = 'product_images require exactly one primary image per product when rows exist'; + END IF; + + RETURN NULL; +END; +$$;--> statement-breakpoint + +CREATE CONSTRAINT TRIGGER product_images_exactly_one_primary_guardrail +AFTER INSERT OR UPDATE OR DELETE ON "product_images" +DEFERRABLE INITIALLY DEFERRED +FOR EACH ROW +EXECUTE FUNCTION shop_product_images_primary_guardrail();--> statement-breakpoint + +CREATE OR REPLACE FUNCTION shop_sync_product_image_mirror() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +DECLARE + affected_product_id uuid; +BEGIN + affected_product_id := coalesce(NEW.product_id, OLD.product_id); + + UPDATE products p + SET + image_url = coalesce( + ( + SELECT pi.image_url + FROM product_images pi + WHERE pi.product_id = affected_product_id + AND pi.is_primary IS TRUE + ORDER BY pi.sort_order ASC, pi.created_at ASC, pi.id ASC + LIMIT 1 + ), + '' + ), + image_public_id = ( + SELECT pi.image_public_id + FROM product_images pi + WHERE pi.product_id = affected_product_id + AND pi.is_primary IS TRUE + ORDER BY pi.sort_order ASC, pi.created_at ASC, pi.id ASC + LIMIT 1 + ), + updated_at = now() + WHERE p.id = affected_product_id; + + RETURN NULL; +END; +$$;--> statement-breakpoint + +CREATE TRIGGER product_images_sync_product_legacy_fields +AFTER INSERT OR UPDATE OR DELETE ON "product_images" +FOR EACH ROW +EXECUTE FUNCTION shop_sync_product_image_mirror(); diff --git a/frontend/drizzle/meta/0033_snapshot.json b/frontend/drizzle/meta/0033_snapshot.json new file mode 100644 index 00000000..55583d73 --- /dev/null +++ b/frontend/drizzle/meta/0033_snapshot.json @@ -0,0 +1,6915 @@ +{ + "id": "3eb20842-8a99-4a84-b730-e128319cc8bb", + "prevId": "976fe687-2e8f-4415-80e3-25c1e299d87a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.blog_author_translations": { + "name": "blog_author_translations", + "schema": "", + "columns": { + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company": { + "name": "company", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "blog_author_translations_author_id_blog_authors_id_fk": { + "name": "blog_author_translations_author_id_blog_authors_id_fk", + "tableFrom": "blog_author_translations", + "tableTo": "blog_authors", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_author_translations_author_id_locale_pk": { + "name": "blog_author_translations_author_id_locale_pk", + "columns": [ + "author_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_authors": { + "name": "blog_authors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_public_id": { + "name": "image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "social_media": { + "name": "social_media", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_authors_slug_unique": { + "name": "blog_authors_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_categories": { + "name": "blog_categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_categories_slug_unique": { + "name": "blog_categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_category_translations": { + "name": "blog_category_translations", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "blog_category_translations_category_id_blog_categories_id_fk": { + "name": "blog_category_translations_category_id_blog_categories_id_fk", + "tableFrom": "blog_category_translations", + "tableTo": "blog_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_category_translations_category_id_locale_pk": { + "name": "blog_category_translations_category_id_locale_pk", + "columns": [ + "category_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_post_categories": { + "name": "blog_post_categories", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blog_post_categories_category_id_idx": { + "name": "blog_post_categories_category_id_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_post_categories_post_id_blog_posts_id_fk": { + "name": "blog_post_categories_post_id_blog_posts_id_fk", + "tableFrom": "blog_post_categories", + "tableTo": "blog_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "blog_post_categories_category_id_blog_categories_id_fk": { + "name": "blog_post_categories_category_id_blog_categories_id_fk", + "tableFrom": "blog_post_categories", + "tableTo": "blog_categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_post_categories_post_id_category_id_pk": { + "name": "blog_post_categories_post_id_category_id_pk", + "columns": [ + "post_id", + "category_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_post_translations": { + "name": "blog_post_translations", + "schema": "", + "columns": { + "post_id": { + "name": "post_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "blog_post_translations_post_id_blog_posts_id_fk": { + "name": "blog_post_translations_post_id_blog_posts_id_fk", + "tableFrom": "blog_post_translations", + "tableTo": "blog_posts", + "columnsFrom": [ + "post_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "blog_post_translations_post_id_locale_pk": { + "name": "blog_post_translations_post_id_locale_pk", + "columns": [ + "post_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blog_posts": { + "name": "blog_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "main_image_url": { + "name": "main_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "main_image_public_id": { + "name": "main_image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "resource_link": { + "name": "resource_link", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scheduled_publish_at": { + "name": "scheduled_publish_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "blog_posts_author_id_idx": { + "name": "blog_posts_author_id_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "blog_posts_author_id_blog_authors_id_fk": { + "name": "blog_posts_author_id_blog_authors_id_fk", + "tableFrom": "blog_posts", + "tableTo": "blog_authors", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "blog_posts_slug_unique": { + "name": "blog_posts_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.categories": { + "name": "categories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "categories_slug_unique": { + "name": "categories_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_translations": { + "name": "category_translations", + "schema": "", + "columns": { + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "category_translations_category_id_categories_id_fk": { + "name": "category_translations_category_id_categories_id_fk", + "tableFrom": "category_translations", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "category_translations_category_id_locale_pk": { + "name": "category_translations_category_id_locale_pk", + "columns": [ + "category_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification_tokens": { + "name": "email_verification_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "email_verification_tokens_user_id_idx": { + "name": "email_verification_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.password_reset_tokens": { + "name": "password_reset_tokens", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "password_reset_tokens_user_id_idx": { + "name": "password_reset_tokens_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.point_transactions": { + "name": "point_transactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source": { + "name": "source", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "default": "'quiz'" + }, + "source_id": { + "name": "source_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "point_transactions_user_id_idx": { + "name": "point_transactions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "point_transactions_user_id_users_id_fk": { + "name": "point_transactions_user_id_users_id_fk", + "tableFrom": "point_transactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.question_translations": { + "name": "question_translations", + "schema": "", + "columns": { + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer_blocks": { + "name": "answer_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "question_translations_question_id_questions_id_fk": { + "name": "question_translations_question_id_questions_id_fk", + "tableFrom": "question_translations", + "tableTo": "questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "question_translations_question_id_locale_pk": { + "name": "question_translations_question_id_locale_pk", + "columns": [ + "question_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.questions": { + "name": "questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'medium'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "questions_category_sort_order_idx": { + "name": "questions_category_sort_order_idx", + "columns": [ + { + "expression": "category_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "questions_category_id_categories_id_fk": { + "name": "questions_category_id_categories_id_fk", + "tableFrom": "questions", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_answer_translations": { + "name": "quiz_answer_translations", + "schema": "", + "columns": { + "quiz_answer_id": { + "name": "quiz_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "answer_text": { + "name": "answer_text", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk": { + "name": "quiz_answer_translations_quiz_answer_id_quiz_answers_id_fk", + "tableFrom": "quiz_answer_translations", + "tableTo": "quiz_answers", + "columnsFrom": [ + "quiz_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_answer_translations_quiz_answer_id_locale_pk": { + "name": "quiz_answer_translations_quiz_answer_id_locale_pk", + "columns": [ + "quiz_answer_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_answers": { + "name": "quiz_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "quiz_answers_question_display_order_idx": { + "name": "quiz_answers_question_display_order_idx", + "columns": [ + { + "expression": "quiz_question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempt_answers": { + "name": "quiz_attempt_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_answer_id": { + "name": "selected_answer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_correct": { + "name": "is_correct", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "answered_at": { + "name": "answered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_attempt_answers_attempt_idx": { + "name": "quiz_attempt_answers_attempt_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk": { + "name": "quiz_attempt_answers_attempt_id_quiz_attempts_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_attempt_answers_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk": { + "name": "quiz_attempt_answers_selected_answer_id_quiz_answers_id_fk", + "tableFrom": "quiz_attempt_answers", + "tableTo": "quiz_answers", + "columnsFrom": [ + "selected_answer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_attempts": { + "name": "quiz_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_questions": { + "name": "total_questions", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "percentage": { + "name": "percentage", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "time_spent_seconds": { + "name": "time_spent_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "integrity_score": { + "name": "integrity_score", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 100 + }, + "points_earned": { + "name": "points_earned", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_attempts_user_id_idx": { + "name": "quiz_attempts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_id_idx": { + "name": "quiz_attempts_quiz_id_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_user_completed_at_idx": { + "name": "quiz_attempts_user_completed_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_percentage_completed_at_idx": { + "name": "quiz_attempts_quiz_percentage_completed_at_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "percentage", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "quiz_attempts_quiz_integrity_score_idx": { + "name": "quiz_attempts_quiz_integrity_score_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integrity_score", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_attempts_user_id_users_id_fk": { + "name": "quiz_attempts_user_id_users_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "quiz_attempts_quiz_id_quizzes_id_fk": { + "name": "quiz_attempts_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_attempts", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_question_content": { + "name": "quiz_question_content", + "schema": "", + "columns": { + "quiz_question_id": { + "name": "quiz_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "question_text": { + "name": "question_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "explanation": { + "name": "explanation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_question_content_quiz_question_id_quiz_questions_id_fk": { + "name": "quiz_question_content_quiz_question_id_quiz_questions_id_fk", + "tableFrom": "quiz_question_content", + "tableTo": "quiz_questions", + "columnsFrom": [ + "quiz_question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_question_content_quiz_question_id_locale_pk": { + "name": "quiz_question_content_quiz_question_id_locale_pk", + "columns": [ + "quiz_question_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_questions": { + "name": "quiz_questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_question_id": { + "name": "source_question_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false, + "default": "'medium'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quiz_questions_quiz_display_order_idx": { + "name": "quiz_questions_quiz_display_order_idx", + "columns": [ + { + "expression": "quiz_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quiz_questions_quiz_id_quizzes_id_fk": { + "name": "quiz_questions_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_questions", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quiz_translations": { + "name": "quiz_translations", + "schema": "", + "columns": { + "quiz_id": { + "name": "quiz_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "varchar(5)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "quiz_translations_quiz_id_quizzes_id_fk": { + "name": "quiz_translations_quiz_id_quizzes_id_fk", + "tableFrom": "quiz_translations", + "tableTo": "quizzes", + "columnsFrom": [ + "quiz_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "quiz_translations_quiz_id_locale_pk": { + "name": "quiz_translations_quiz_id_locale_pk", + "columns": [ + "quiz_id", + "locale" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.quizzes": { + "name": "quizzes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "category_id": { + "name": "category_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "questions_count": { + "name": "questions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "time_limit_seconds": { + "name": "time_limit_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "status": { + "name": "status", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true, + "default": "'ready'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "quizzes_slug_idx": { + "name": "quizzes_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "quizzes_category_id_categories_id_fk": { + "name": "quizzes_category_id_categories_id_fk", + "tableFrom": "quizzes", + "tableTo": "categories", + "columnsFrom": [ + "category_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "quizzes_category_id_slug_unique": { + "name": "quizzes_category_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "category_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.active_sessions": { + "name": "active_sessions", + "schema": "", + "columns": { + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "last_activity": { + "name": "last_activity", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "active_sessions_last_activity_idx": { + "name": "active_sessions_last_activity_idx", + "columns": [ + { + "expression": "last_activity", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_audit_log": { + "name": "admin_audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_audit_log_dedupe_key_uq": { + "name": "admin_audit_log_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_order_id_idx": { + "name": "admin_audit_log_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_actor_user_id_idx": { + "name": "admin_audit_log_actor_user_id_idx", + "columns": [ + { + "expression": "actor_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_audit_log_occurred_at_idx": { + "name": "admin_audit_log_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_audit_log_order_id_orders_id_fk": { + "name": "admin_audit_log_order_id_orders_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "admin_audit_log_actor_user_id_users_id_fk": { + "name": "admin_audit_log_actor_user_id_users_id_fk", + "tableFrom": "admin_audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_rate_limits": { + "name": "api_rate_limits", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "window_started_at": { + "name": "window_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "api_rate_limits_updated_at_idx": { + "name": "api_rate_limits_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "api_rate_limits_count_non_negative": { + "name": "api_rate_limits_count_non_negative", + "value": "\"api_rate_limits\".\"count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.internal_job_state": { + "name": "internal_job_state", + "schema": "", + "columns": { + "job_name": { + "name": "job_name", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "next_allowed_at": { + "name": "next_allowed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_run_id": { + "name": "last_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.inventory_moves": { + "name": "inventory_moves", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "move_key": { + "name": "move_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "inventory_move_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inventory_moves_move_key_uq": { + "name": "inventory_moves_move_key_uq", + "columns": [ + { + "expression": "move_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_order_id_idx": { + "name": "inventory_moves_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inventory_moves_product_id_idx": { + "name": "inventory_moves_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "inventory_moves_order_id_orders_id_fk": { + "name": "inventory_moves_order_id_orders_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inventory_moves_product_id_products_id_fk": { + "name": "inventory_moves_product_id_products_id_fk", + "tableFrom": "inventory_moves", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "inventory_moves_quantity_gt_0": { + "name": "inventory_moves_quantity_gt_0", + "value": "\"inventory_moves\".\"quantity\" > 0" + } + }, + "isRLSEnabled": false + }, + "public.monobank_events": { + "name": "monobank_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monobank'" + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "ccy": { + "name": "ccy", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "reference": { + "name": "reference", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "normalized_payload": { + "name": "normalized_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "applied_result": { + "name": "applied_result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_error_code": { + "name": "applied_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_error_message": { + "name": "applied_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_sha256": { + "name": "raw_sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_events_event_key_unique": { + "name": "monobank_events_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_raw_sha256_unique": { + "name": "monobank_events_raw_sha256_unique", + "columns": [ + { + "expression": "raw_sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_order_id_idx": { + "name": "monobank_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_attempt_id_idx": { + "name": "monobank_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_events_claim_expires_idx": { + "name": "monobank_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_events_attempt_id_payment_attempts_id_fk": { + "name": "monobank_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "monobank_events_order_id_orders_id_fk": { + "name": "monobank_events_order_id_orders_id_fk", + "tableFrom": "monobank_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_events_provider_check": { + "name": "monobank_events_provider_check", + "value": "\"monobank_events\".\"provider\" in ('monobank')" + } + }, + "isRLSEnabled": false + }, + "public.monobank_payment_cancels": { + "name": "monobank_payment_cancels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ext_ref": { + "name": "ext_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invoice_id": { + "name": "invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_response": { + "name": "psp_response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_payment_cancels_ext_ref_unique": { + "name": "monobank_payment_cancels_ext_ref_unique", + "columns": [ + { + "expression": "ext_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_payment_cancels_order_id_idx": { + "name": "monobank_payment_cancels_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_payment_cancels_attempt_id_idx": { + "name": "monobank_payment_cancels_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_payment_cancels_order_id_orders_id_fk": { + "name": "monobank_payment_cancels_order_id_orders_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_payment_cancels_attempt_id_payment_attempts_id_fk": { + "name": "monobank_payment_cancels_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_payment_cancels", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_payment_cancels_status_check": { + "name": "monobank_payment_cancels_status_check", + "value": "\"monobank_payment_cancels\".\"status\" in ('requested','processing','success','failure')" + } + }, + "isRLSEnabled": false + }, + "public.monobank_refunds": { + "name": "monobank_refunds", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'monobank'" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "ext_ref": { + "name": "ext_ref", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'UAH'" + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "monobank_refunds_ext_ref_unique": { + "name": "monobank_refunds_ext_ref_unique", + "columns": [ + { + "expression": "ext_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_order_id_idx": { + "name": "monobank_refunds_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "monobank_refunds_attempt_id_idx": { + "name": "monobank_refunds_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "monobank_refunds_order_id_orders_id_fk": { + "name": "monobank_refunds_order_id_orders_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monobank_refunds_attempt_id_payment_attempts_id_fk": { + "name": "monobank_refunds_attempt_id_payment_attempts_id_fk", + "tableFrom": "monobank_refunds", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "monobank_refunds_provider_check": { + "name": "monobank_refunds_provider_check", + "value": "\"monobank_refunds\".\"provider\" in ('monobank')" + }, + "monobank_refunds_status_check": { + "name": "monobank_refunds_status_check", + "value": "\"monobank_refunds\".\"status\" in ('requested','processing','success','failure','needs_review')" + }, + "monobank_refunds_amount_minor_non_negative": { + "name": "monobank_refunds_amount_minor_non_negative", + "value": "\"monobank_refunds\".\"amount_minor\" >= 0" + }, + "monobank_refunds_currency_uah": { + "name": "monobank_refunds_currency_uah", + "value": "\"monobank_refunds\".\"currency\" = 'UAH'" + } + }, + "isRLSEnabled": false + }, + "public.notification_outbox": { + "name": "notification_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel": { + "name": "channel", + "type": "notification_channel", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'email'" + }, + "template_key": { + "name": "template_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_domain": { + "name": "source_domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_event_id": { + "name": "source_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 5 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "lease_owner": { + "name": "lease_owner", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dead_lettered_at": { + "name": "dead_lettered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notification_outbox_dedupe_key_uq": { + "name": "notification_outbox_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_next_attempt_idx": { + "name": "notification_outbox_status_next_attempt_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_status_lease_expires_idx": { + "name": "notification_outbox_status_lease_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_order_created_idx": { + "name": "notification_outbox_order_created_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "notification_outbox_template_status_idx": { + "name": "notification_outbox_template_status_idx", + "columns": [ + { + "expression": "template_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notification_outbox_order_id_orders_id_fk": { + "name": "notification_outbox_order_id_orders_id_fk", + "tableFrom": "notification_outbox", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "notification_outbox_source_domain_chk": { + "name": "notification_outbox_source_domain_chk", + "value": "\"notification_outbox\".\"source_domain\" in ('shipping_event','payment_event')" + }, + "notification_outbox_status_chk": { + "name": "notification_outbox_status_chk", + "value": "\"notification_outbox\".\"status\" in ('pending','processing','sent','failed','dead_letter')" + }, + "notification_outbox_attempt_count_non_negative_chk": { + "name": "notification_outbox_attempt_count_non_negative_chk", + "value": "\"notification_outbox\".\"attempt_count\" >= 0" + }, + "notification_outbox_max_attempts_positive_chk": { + "name": "notification_outbox_max_attempts_positive_chk", + "value": "\"notification_outbox\".\"max_attempts\" >= 1" + } + }, + "isRLSEnabled": false + }, + "public.np_cities": { + "name": "np_cities", + "schema": "", + "columns": { + "ref": { + "name": "ref", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name_ua": { + "name": "name_ua", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_ru": { + "name": "name_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "area": { + "name": "area", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settlement_type": { + "name": "settlement_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_sync_run_id": { + "name": "last_sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "np_cities_active_name_idx": { + "name": "np_cities_active_name_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_ua", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_cities_last_sync_run_idx": { + "name": "np_cities_last_sync_run_idx", + "columns": [ + { + "expression": "last_sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_cities_active_name_prefix_idx": { + "name": "np_cities_active_name_prefix_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name_ua", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.np_warehouses": { + "name": "np_warehouses", + "schema": "", + "columns": { + "ref": { + "name": "ref", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "city_ref": { + "name": "city_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settlement_ref": { + "name": "settlement_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_ru": { + "name": "name_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "address_ru": { + "name": "address_ru", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_post_machine": { + "name": "is_post_machine", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_sync_run_id": { + "name": "last_sync_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "np_warehouses_settlement_active_idx": { + "name": "np_warehouses_settlement_active_idx", + "columns": [ + { + "expression": "settlement_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_warehouses_city_active_idx": { + "name": "np_warehouses_city_active_idx", + "columns": [ + { + "expression": "city_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_warehouses_active_name_idx": { + "name": "np_warehouses_active_name_idx", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "np_warehouses_last_sync_run_idx": { + "name": "np_warehouses_last_sync_run_idx", + "columns": [ + { + "expression": "last_sync_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "np_warehouses_city_ref_np_cities_ref_fk": { + "name": "np_warehouses_city_ref_np_cities_ref_fk", + "tableFrom": "np_warehouses", + "tableTo": "np_cities", + "columnsFrom": [ + "city_ref" + ], + "columnsTo": [ + "ref" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.order_items": { + "name": "order_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "selected_size": { + "name": "selected_size", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "selected_color": { + "name": "selected_color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_total_minor": { + "name": "line_total_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price": { + "name": "unit_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "line_total": { + "name": "line_total", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "product_title": { + "name": "product_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_slug": { + "name": "product_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_sku": { + "name": "product_sku", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "order_items_order_id_idx": { + "name": "order_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "order_items_order_variant_uq": { + "name": "order_items_order_variant_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "selected_size", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "selected_color", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_items_order_id_orders_id_fk": { + "name": "order_items_order_id_orders_id_fk", + "tableFrom": "order_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "order_items_product_id_products_id_fk": { + "name": "order_items_product_id_products_id_fk", + "tableFrom": "order_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "order_items_quantity_positive": { + "name": "order_items_quantity_positive", + "value": "\"order_items\".\"quantity\" > 0" + }, + "order_items_unit_price_minor_non_negative": { + "name": "order_items_unit_price_minor_non_negative", + "value": "\"order_items\".\"unit_price_minor\" >= 0" + }, + "order_items_line_total_minor_non_negative": { + "name": "order_items_line_total_minor_non_negative", + "value": "\"order_items\".\"line_total_minor\" >= 0" + }, + "order_items_line_total_consistent": { + "name": "order_items_line_total_consistent", + "value": "\"order_items\".\"line_total_minor\" = \"order_items\".\"unit_price_minor\" * \"order_items\".\"quantity\"" + }, + "order_items_unit_price_mirror_consistent": { + "name": "order_items_unit_price_mirror_consistent", + "value": "\"order_items\".\"unit_price\" = (\"order_items\".\"unit_price_minor\"::numeric / 100)" + }, + "order_items_line_total_mirror_consistent": { + "name": "order_items_line_total_mirror_consistent", + "value": "\"order_items\".\"line_total\" = (\"order_items\".\"line_total_minor\"::numeric / 100)" + } + }, + "isRLSEnabled": false + }, + "public.order_legal_consents": { + "name": "order_legal_consents", + "schema": "", + "columns": { + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "privacy_accepted": { + "name": "privacy_accepted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "terms_version": { + "name": "terms_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "privacy_version": { + "name": "privacy_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "consented_at": { + "name": "consented_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'checkout'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country": { + "name": "country", + "type": "varchar(2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "order_legal_consents_consented_idx": { + "name": "order_legal_consents_consented_idx", + "columns": [ + { + "expression": "consented_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_legal_consents_order_id_orders_id_fk": { + "name": "order_legal_consents_order_id_orders_id_fk", + "tableFrom": "order_legal_consents", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "order_legal_consents_terms_accepted_chk": { + "name": "order_legal_consents_terms_accepted_chk", + "value": "\"order_legal_consents\".\"terms_accepted\" = true" + }, + "order_legal_consents_privacy_accepted_chk": { + "name": "order_legal_consents_privacy_accepted_chk", + "value": "\"order_legal_consents\".\"privacy_accepted\" = true" + } + }, + "isRLSEnabled": false + }, + "public.order_shipping": { + "name": "order_shipping", + "schema": "", + "columns": { + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "shipping_address": { + "name": "shipping_address", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "order_shipping_updated_idx": { + "name": "order_shipping_updated_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "order_shipping_order_id_orders_id_fk": { + "name": "order_shipping_order_id_orders_id_fk", + "tableFrom": "order_shipping", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_amount_minor": { + "name": "total_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "total_amount": { + "name": "total_amount", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "fulfillment_mode": { + "name": "fulfillment_mode", + "type": "fulfillment_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ua_np'" + }, + "quote_status": { + "name": "quote_status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "quote_version": { + "name": "quote_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "items_subtotal_minor": { + "name": "items_subtotal_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "quote_accepted_at": { + "name": "quote_accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "quote_payment_deadline_at": { + "name": "quote_payment_deadline_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "shipping_required": { + "name": "shipping_required", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "shipping_payer": { + "name": "shipping_payer", + "type": "shipping_payer", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_provider": { + "name": "shipping_provider", + "type": "shipping_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_method_code": { + "name": "shipping_method_code", + "type": "shipping_method_code", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "shipping_amount_minor": { + "name": "shipping_amount_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "shipping_status": { + "name": "shipping_status", + "type": "shipping_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shipping_provider_ref": { + "name": "shipping_provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "payment_provider": { + "name": "payment_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_charge_id": { + "name": "psp_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_payment_method": { + "name": "psp_payment_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_status_reason": { + "name": "psp_status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "psp_metadata": { + "name": "psp_metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "order_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'CREATED'" + }, + "inventory_status": { + "name": "inventory_status", + "type": "inventory_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "failure_code": { + "name": "failure_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "failure_message": { + "name": "failure_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "idempotency_request_hash": { + "name": "idempotency_request_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stock_restored": { + "name": "stock_restored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "restocked_at": { + "name": "restocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "sweep_claimed_at": { + "name": "sweep_claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sweep_claim_expires_at": { + "name": "sweep_claim_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sweep_run_id": { + "name": "sweep_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sweep_claimed_by": { + "name": "sweep_claimed_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "orders_sweep_claim_expires_idx": { + "name": "orders_sweep_claim_expires_idx", + "columns": [ + { + "expression": "sweep_claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orders_user_id_created_at": { + "name": "idx_orders_user_id_created_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_shipping_status_idx": { + "name": "orders_shipping_status_idx", + "columns": [ + { + "expression": "shipping_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_deadline_idx": { + "name": "orders_quote_status_deadline_idx", + "columns": [ + { + "expression": "fulfillment_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quote_payment_deadline_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "orders_quote_status_updated_idx": { + "name": "orders_quote_status_updated_idx", + "columns": [ + { + "expression": "quote_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_users_id_fk": { + "name": "orders_user_id_users_id_fk", + "tableFrom": "orders", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "orders_idempotency_key_unique": { + "name": "orders_idempotency_key_unique", + "nullsNotDistinct": false, + "columns": [ + "idempotency_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "orders_payment_provider_valid": { + "name": "orders_payment_provider_valid", + "value": "\"orders\".\"payment_provider\" in ('stripe', 'monobank', 'none')" + }, + "orders_total_amount_minor_non_negative": { + "name": "orders_total_amount_minor_non_negative", + "value": "\"orders\".\"total_amount_minor\" >= 0" + }, + "orders_items_subtotal_minor_non_negative": { + "name": "orders_items_subtotal_minor_non_negative", + "value": "\"orders\".\"items_subtotal_minor\" >= 0" + }, + "orders_shipping_quote_minor_non_negative": { + "name": "orders_shipping_quote_minor_non_negative", + "value": "\"orders\".\"shipping_quote_minor\" is null or \"orders\".\"shipping_quote_minor\" >= 0" + }, + "orders_payment_intent_id_null_when_none": { + "name": "orders_payment_intent_id_null_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR \"orders\".\"payment_intent_id\" IS NULL" + }, + "orders_psp_fields_null_when_none": { + "name": "orders_psp_fields_null_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR (\n \"orders\".\"psp_charge_id\" IS NULL AND\n \"orders\".\"psp_payment_method\" IS NULL AND\n \"orders\".\"psp_status_reason\" IS NULL\n )" + }, + "orders_total_amount_mirror_consistent": { + "name": "orders_total_amount_mirror_consistent", + "value": "\"orders\".\"total_amount\" = (\"orders\".\"total_amount_minor\"::numeric / 100)" + }, + "orders_payment_status_valid_when_none": { + "name": "orders_payment_status_valid_when_none", + "value": "\"orders\".\"payment_provider\" <> 'none' OR \"orders\".\"payment_status\" in ('paid','failed')" + }, + "orders_shipping_null_when_not_required_chk": { + "name": "orders_shipping_null_when_not_required_chk", + "value": "\n \"orders\".\"shipping_required\" IS TRUE\n OR (\n \"orders\".\"shipping_provider\" IS NULL\n AND \"orders\".\"shipping_method_code\" IS NULL\n AND \"orders\".\"shipping_status\" IS NULL\n )\n " + }, + "orders_shipping_present_when_required_chk": { + "name": "orders_shipping_present_when_required_chk", + "value": "\n \"orders\".\"shipping_required\" IS DISTINCT FROM TRUE\n OR (\n \"orders\".\"shipping_provider\" IS NOT NULL\n AND \"orders\".\"shipping_method_code\" IS NOT NULL\n AND \"orders\".\"shipping_status\" IS NOT NULL\n )\n " + }, + "orders_shipping_amount_minor_non_negative_chk": { + "name": "orders_shipping_amount_minor_non_negative_chk", + "value": "\"orders\".\"shipping_amount_minor\" IS NULL OR \"orders\".\"shipping_amount_minor\" >= 0" + }, + "orders_shipping_payer_null_when_not_required_chk": { + "name": "orders_shipping_payer_null_when_not_required_chk", + "value": "\"orders\".\"shipping_required\" IS TRUE OR \"orders\".\"shipping_payer\" IS NULL" + }, + "orders_shipping_payer_present_when_required_chk": { + "name": "orders_shipping_payer_present_when_required_chk", + "value": "\"orders\".\"shipping_required\" IS DISTINCT FROM TRUE OR \"orders\".\"shipping_payer\" IS NOT NULL" + }, + "orders_terminal_shipping_status_chk": { + "name": "orders_terminal_shipping_status_chk", + "value": "\n (\n \"orders\".\"payment_status\" NOT IN ('failed', 'refunded')\n AND \"orders\".\"status\" NOT IN ('CANCELED', 'INVENTORY_FAILED')\n )\n OR (\n \"orders\".\"shipping_status\" IS NULL\n OR \"orders\".\"shipping_status\" IN ('cancelled', 'delivered')\n )\n " + }, + "orders_intl_provider_restriction_chk": { + "name": "orders_intl_provider_restriction_chk", + "value": "\"orders\".\"fulfillment_mode\" <> 'intl' OR \"orders\".\"payment_provider\" in ('stripe', 'none')" + } + }, + "isRLSEnabled": false + }, + "public.payment_attempts": { + "name": "payment_attempts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "attempt_number": { + "name": "attempt_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "expected_amount_minor": { + "name": "expected_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checkout_url": { + "name": "checkout_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_created_at": { + "name": "provider_created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "provider_modified_at": { + "name": "provider_modified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "janitor_claimed_until": { + "name": "janitor_claimed_until", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "janitor_claimed_by": { + "name": "janitor_claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finalized_at": { + "name": "finalized_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "payment_attempts_order_provider_attempt_unique": { + "name": "payment_attempts_order_provider_attempt_unique", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "attempt_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_idempotency_key_unique": { + "name": "payment_attempts_idempotency_key_unique", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_provider_pi_unique": { + "name": "payment_attempts_provider_pi_unique", + "columns": [ + { + "expression": "provider_payment_intent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_order_provider_status_idx": { + "name": "payment_attempts_order_provider_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_order_provider_active_unique": { + "name": "payment_attempts_order_provider_active_unique", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"payment_attempts\".\"status\" in ('active','creating')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_provider_status_updated_idx": { + "name": "payment_attempts_provider_status_updated_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_attempts_janitor_claim_idx": { + "name": "payment_attempts_janitor_claim_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "janitor_claimed_until", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_attempts_order_id_orders_id_fk": { + "name": "payment_attempts_order_id_orders_id_fk", + "tableFrom": "payment_attempts", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "payment_attempts_provider_check": { + "name": "payment_attempts_provider_check", + "value": "\"payment_attempts\".\"provider\" in ('stripe','monobank')" + }, + "payment_attempts_status_check": { + "name": "payment_attempts_status_check", + "value": "\"payment_attempts\".\"status\" in ('creating','active','succeeded','failed','canceled')" + }, + "payment_attempts_attempt_number_check": { + "name": "payment_attempts_attempt_number_check", + "value": "\"payment_attempts\".\"attempt_number\" >= 1" + }, + "payment_attempts_expected_amount_minor_non_negative": { + "name": "payment_attempts_expected_amount_minor_non_negative", + "value": "\"payment_attempts\".\"expected_amount_minor\" is null or \"payment_attempts\".\"expected_amount_minor\" >= 0" + }, + "payment_attempts_mono_currency_uah": { + "name": "payment_attempts_mono_currency_uah", + "value": "\"payment_attempts\".\"provider\" <> 'monobank' OR \"payment_attempts\".\"currency\" = 'UAH'" + } + }, + "isRLSEnabled": false + }, + "public.payment_events": { + "name": "payment_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_id": { + "name": "attempt_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider_payment_intent_id": { + "name": "provider_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_charge_id": { + "name": "provider_charge_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_minor": { + "name": "amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "payment_events_dedupe_key_uq": { + "name": "payment_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_order_id_idx": { + "name": "payment_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_attempt_id_idx": { + "name": "payment_events_attempt_id_idx", + "columns": [ + { + "expression": "attempt_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_event_ref_idx": { + "name": "payment_events_event_ref_idx", + "columns": [ + { + "expression": "event_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "payment_events_occurred_at_idx": { + "name": "payment_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "payment_events_order_id_orders_id_fk": { + "name": "payment_events_order_id_orders_id_fk", + "tableFrom": "payment_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payment_events_attempt_id_payment_attempts_id_fk": { + "name": "payment_events_attempt_id_payment_attempts_id_fk", + "tableFrom": "payment_events", + "tableTo": "payment_attempts", + "columnsFrom": [ + "attempt_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_images": { + "name": "product_images", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_public_id": { + "name": "image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_images_product_id_idx": { + "name": "product_images_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_images_product_sort_order_uq": { + "name": "product_images_product_sort_order_uq", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_images_one_primary_per_product_uq": { + "name": "product_images_one_primary_per_product_uq", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"product_images\".\"is_primary\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_images_product_id_products_id_fk": { + "name": "product_images_product_id_products_id_fk", + "tableFrom": "product_images", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "product_images_sort_order_non_negative": { + "name": "product_images_sort_order_non_negative", + "value": "\"product_images\".\"sort_order\" >= 0" + }, + "product_images_image_url_non_blank": { + "name": "product_images_image_url_non_blank", + "value": "length(btrim(\"product_images\".\"image_url\")) > 0" + } + }, + "isRLSEnabled": false + }, + "public.product_prices": { + "name": "product_prices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "price_minor": { + "name": "price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "original_price_minor": { + "name": "original_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "original_price": { + "name": "original_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_prices_product_id_idx": { + "name": "product_prices_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_prices_product_currency_uq": { + "name": "product_prices_product_currency_uq", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "currency", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "product_prices_product_id_products_id_fk": { + "name": "product_prices_product_id_products_id_fk", + "tableFrom": "product_prices", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "product_prices_price_positive": { + "name": "product_prices_price_positive", + "value": "\"product_prices\".\"price_minor\" > 0" + }, + "product_prices_original_price_valid": { + "name": "product_prices_original_price_valid", + "value": "\"product_prices\".\"original_price_minor\" is null or \"product_prices\".\"original_price_minor\" > \"product_prices\".\"price_minor\"" + }, + "product_prices_price_mirror_consistent": { + "name": "product_prices_price_mirror_consistent", + "value": "\"product_prices\".\"price\" = (\"product_prices\".\"price_minor\"::numeric / 100)" + }, + "product_prices_original_price_null_coupled": { + "name": "product_prices_original_price_null_coupled", + "value": "(\"product_prices\".\"original_price_minor\" is null) = (\"product_prices\".\"original_price\" is null)" + }, + "product_prices_original_price_mirror_consistent": { + "name": "product_prices_original_price_mirror_consistent", + "value": "\"product_prices\".\"original_price_minor\" is null or \"product_prices\".\"original_price\" = (\"product_prices\".\"original_price_minor\"::numeric / 100)" + } + }, + "isRLSEnabled": false + }, + "public.products": { + "name": "products", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "image_public_id": { + "name": "image_public_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price": { + "name": "price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": true + }, + "original_price": { + "name": "original_price", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "colors": { + "name": "colors", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "sizes": { + "name": "sizes", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "badge": { + "name": "badge", + "type": "product_badge", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'NONE'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_featured": { + "name": "is_featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "stock": { + "name": "stock", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sku": { + "name": "sku", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "products_slug_unique": { + "name": "products_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "products_stock_non_negative": { + "name": "products_stock_non_negative", + "value": "\"products\".\"stock\" >= 0" + }, + "products_currency_usd_only": { + "name": "products_currency_usd_only", + "value": "\"products\".\"currency\" = 'USD'" + }, + "products_price_positive": { + "name": "products_price_positive", + "value": "\"products\".\"price\" > 0" + }, + "products_original_price_valid": { + "name": "products_original_price_valid", + "value": "\"products\".\"original_price\" is null or \"products\".\"original_price\" > \"products\".\"price\"" + } + }, + "isRLSEnabled": false + }, + "public.return_items": { + "name": "return_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "return_request_id": { + "name": "return_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_item_id": { + "name": "order_item_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unit_price_minor": { + "name": "unit_price_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "line_total_minor": { + "name": "line_total_minor", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(200)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "return_items_idempotency_key_uq": { + "name": "return_items_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_return_request_idx": { + "name": "return_items_return_request_idx", + "columns": [ + { + "expression": "return_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_order_id_idx": { + "name": "return_items_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_items_product_id_idx": { + "name": "return_items_product_id_idx", + "columns": [ + { + "expression": "product_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_items_return_request_id_return_requests_id_fk": { + "name": "return_items_return_request_id_return_requests_id_fk", + "tableFrom": "return_items", + "tableTo": "return_requests", + "columnsFrom": [ + "return_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_id_orders_id_fk": { + "name": "return_items_order_id_orders_id_fk", + "tableFrom": "return_items", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_items_order_item_id_order_items_id_fk": { + "name": "return_items_order_item_id_order_items_id_fk", + "tableFrom": "return_items", + "tableTo": "order_items", + "columnsFrom": [ + "order_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_items_product_id_products_id_fk": { + "name": "return_items_product_id_products_id_fk", + "tableFrom": "return_items", + "tableTo": "products", + "columnsFrom": [ + "product_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_items_return_request_order_fk": { + "name": "return_items_return_request_order_fk", + "tableFrom": "return_items", + "tableTo": "return_requests", + "columnsFrom": [ + "return_request_id", + "order_id" + ], + "columnsTo": [ + "id", + "order_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_items_quantity_positive_chk": { + "name": "return_items_quantity_positive_chk", + "value": "\"return_items\".\"quantity\" > 0" + }, + "return_items_unit_price_minor_non_negative_chk": { + "name": "return_items_unit_price_minor_non_negative_chk", + "value": "\"return_items\".\"unit_price_minor\" >= 0" + }, + "return_items_line_total_minor_non_negative_chk": { + "name": "return_items_line_total_minor_non_negative_chk", + "value": "\"return_items\".\"line_total_minor\" >= 0" + }, + "return_items_line_total_consistent_chk": { + "name": "return_items_line_total_consistent_chk", + "value": "\"return_items\".\"line_total_minor\" = (\"return_items\".\"unit_price_minor\" * \"return_items\".\"quantity\")" + } + }, + "isRLSEnabled": false + }, + "public.return_requests": { + "name": "return_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "return_request_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy_restock": { + "name": "policy_restock", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "refund_amount_minor": { + "name": "refund_amount_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "approved_at": { + "name": "approved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "approved_by": { + "name": "approved_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejected_at": { + "name": "rejected_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "rejected_by": { + "name": "rejected_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "received_by": { + "name": "received_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refunded_at": { + "name": "refunded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refunded_by": { + "name": "refunded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refund_provider_ref": { + "name": "refund_provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "return_requests_order_id_uq": { + "name": "return_requests_order_id_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_id_order_id_uq": { + "name": "return_requests_id_order_id_uq", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_idempotency_key_uq": { + "name": "return_requests_idempotency_key_uq", + "columns": [ + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_status_created_idx": { + "name": "return_requests_status_created_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "return_requests_user_id_created_idx": { + "name": "return_requests_user_id_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "return_requests_order_id_orders_id_fk": { + "name": "return_requests_order_id_orders_id_fk", + "tableFrom": "return_requests", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "return_requests_user_id_users_id_fk": { + "name": "return_requests_user_id_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_approved_by_users_id_fk": { + "name": "return_requests_approved_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "approved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_rejected_by_users_id_fk": { + "name": "return_requests_rejected_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "rejected_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_received_by_users_id_fk": { + "name": "return_requests_received_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "received_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "return_requests_refunded_by_users_id_fk": { + "name": "return_requests_refunded_by_users_id_fk", + "tableFrom": "return_requests", + "tableTo": "users", + "columnsFrom": [ + "refunded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "return_requests_refund_amount_minor_non_negative_chk": { + "name": "return_requests_refund_amount_minor_non_negative_chk", + "value": "\"return_requests\".\"refund_amount_minor\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.shipping_events": { + "name": "shipping_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "shipment_id": { + "name": "shipment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_name": { + "name": "event_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_source": { + "name": "event_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_ref": { + "name": "event_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_from": { + "name": "status_from", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_to": { + "name": "status_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_events_dedupe_key_uq": { + "name": "shipping_events_dedupe_key_uq", + "columns": [ + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_order_id_idx": { + "name": "shipping_events_order_id_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_shipment_id_idx": { + "name": "shipping_events_shipment_id_idx", + "columns": [ + { + "expression": "shipment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_events_occurred_at_idx": { + "name": "shipping_events_occurred_at_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_events_order_id_orders_id_fk": { + "name": "shipping_events_order_id_orders_id_fk", + "tableFrom": "shipping_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_events_shipment_id_shipping_shipments_id_fk": { + "name": "shipping_events_shipment_id_shipping_shipments_id_fk", + "tableFrom": "shipping_events", + "tableTo": "shipping_shipments", + "columnsFrom": [ + "shipment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shipping_quotes": { + "name": "shipping_quotes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "quote_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "currency", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "shipping_quote_minor": { + "name": "shipping_quote_minor", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "offered_by": { + "name": "offered_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "offered_at": { + "name": "offered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "declined_at": { + "name": "declined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_quotes_order_version_uq": { + "name": "shipping_quotes_order_version_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_status_idx": { + "name": "shipping_quotes_order_status_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_status_expires_idx": { + "name": "shipping_quotes_status_expires_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_quotes_order_updated_idx": { + "name": "shipping_quotes_order_updated_idx", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_quotes_order_id_orders_id_fk": { + "name": "shipping_quotes_order_id_orders_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shipping_quotes_offered_by_users_id_fk": { + "name": "shipping_quotes_offered_by_users_id_fk", + "tableFrom": "shipping_quotes", + "tableTo": "users", + "columnsFrom": [ + "offered_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_quotes_version_positive_chk": { + "name": "shipping_quotes_version_positive_chk", + "value": "\"shipping_quotes\".\"version\" >= 1" + }, + "shipping_quotes_quote_minor_non_negative_chk": { + "name": "shipping_quotes_quote_minor_non_negative_chk", + "value": "\"shipping_quotes\".\"shipping_quote_minor\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.shipping_shipments": { + "name": "shipping_shipments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "shipping_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'nova_poshta'" + }, + "status": { + "name": "status", + "type": "shipping_shipment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "next_attempt_at": { + "name": "next_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error_code": { + "name": "last_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_error_message": { + "name": "last_error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_ref": { + "name": "provider_ref", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tracking_number": { + "name": "tracking_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "lease_expires_at": { + "name": "lease_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shipping_shipments_order_id_uq": { + "name": "shipping_shipments_order_id_uq", + "columns": [ + { + "expression": "order_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_queue_idx": { + "name": "shipping_shipments_queue_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_attempt_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_lease_idx": { + "name": "shipping_shipments_lease_idx", + "columns": [ + { + "expression": "lease_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shipping_shipments_provider_ref_idx": { + "name": "shipping_shipments_provider_ref_idx", + "columns": [ + { + "expression": "provider_ref", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shipping_shipments_order_id_orders_id_fk": { + "name": "shipping_shipments_order_id_orders_id_fk", + "tableFrom": "shipping_shipments", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "shipping_shipments_attempt_count_non_negative_chk": { + "name": "shipping_shipments_attempt_count_non_negative_chk", + "value": "\"shipping_shipments\".\"attempt_count\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.stripe_events": { + "name": "stripe_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'stripe'" + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_intent_id": { + "name": "payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order_id": { + "name": "order_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claim_expires_at": { + "name": "claim_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_events_event_id_idx": { + "name": "stripe_events_event_id_idx", + "columns": [ + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "stripe_events_claim_expires_idx": { + "name": "stripe_events_claim_expires_idx", + "columns": [ + { + "expression": "claim_expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "stripe_events_order_id_orders_id_fk": { + "name": "stripe_events_order_id_orders_id_fk", + "tableFrom": "stripe_events", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'credentials'" + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "users_provider_provider_id_unique": { + "name": "users_provider_provider_id_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.currency": { + "name": "currency", + "schema": "public", + "values": [ + "USD", + "UAH" + ] + }, + "public.fulfillment_mode": { + "name": "fulfillment_mode", + "schema": "public", + "values": [ + "ua_np", + "intl" + ] + }, + "public.inventory_move_type": { + "name": "inventory_move_type", + "schema": "public", + "values": [ + "reserve", + "release" + ] + }, + "public.inventory_status": { + "name": "inventory_status", + "schema": "public", + "values": [ + "none", + "reserving", + "reserved", + "release_pending", + "released", + "failed" + ] + }, + "public.notification_channel": { + "name": "notification_channel", + "schema": "public", + "values": [ + "email", + "sms" + ] + }, + "public.order_status": { + "name": "order_status", + "schema": "public", + "values": [ + "CREATED", + "INVENTORY_RESERVED", + "INVENTORY_FAILED", + "PAID", + "CANCELED" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "requires_payment", + "paid", + "failed", + "refunded", + "needs_review" + ] + }, + "public.product_badge": { + "name": "product_badge", + "schema": "public", + "values": [ + "NEW", + "SALE", + "NONE" + ] + }, + "public.quote_status": { + "name": "quote_status", + "schema": "public", + "values": [ + "none", + "requested", + "offered", + "accepted", + "declined", + "expired", + "requires_requote" + ] + }, + "public.return_request_status": { + "name": "return_request_status", + "schema": "public", + "values": [ + "requested", + "approved", + "rejected", + "received", + "refunded" + ] + }, + "public.shipping_method_code": { + "name": "shipping_method_code", + "schema": "public", + "values": [ + "NP_WAREHOUSE", + "NP_LOCKER", + "NP_COURIER" + ] + }, + "public.shipping_payer": { + "name": "shipping_payer", + "schema": "public", + "values": [ + "customer", + "merchant" + ] + }, + "public.shipping_provider": { + "name": "shipping_provider", + "schema": "public", + "values": [ + "nova_poshta", + "ukrposhta" + ] + }, + "public.shipping_shipment_status": { + "name": "shipping_shipment_status", + "schema": "public", + "values": [ + "queued", + "processing", + "succeeded", + "failed", + "needs_attention" + ] + }, + "public.shipping_status": { + "name": "shipping_status", + "schema": "public", + "values": [ + "pending", + "queued", + "creating_label", + "label_created", + "shipped", + "delivered", + "cancelled", + "needs_attention" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/frontend/drizzle/meta/_journal.json b/frontend/drizzle/meta/_journal.json index 23d55722..6390446d 100644 --- a/frontend/drizzle/meta/_journal.json +++ b/frontend/drizzle/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1773359486547, "tag": "0032_shipping_guardrails_followup", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1774462106078, + "tag": "0033_marvelous_arclight", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/frontend/lib/services/products/admin/queries.ts b/frontend/lib/services/products/admin/queries.ts index d2df6453..ad039645 100644 --- a/frontend/lib/services/products/admin/queries.ts +++ b/frontend/lib/services/products/admin/queries.ts @@ -6,7 +6,7 @@ import { ProductNotFoundError } from '@/lib/errors/products'; import type { CurrencyCode } from '@/lib/shop/currency'; import type { DbProduct } from '@/lib/types/shop'; -import { mapRowToProduct } from '../mapping'; +import { mapRowsToProducts, mapRowToProduct } from '../mapping'; import { assertMoneyMinorInt } from '../prices'; import type { AdminProductPriceRow, AdminProductsFilter } from '../types'; @@ -21,7 +21,7 @@ export async function getAdminProductById(id: string): Promise { throw new ProductNotFoundError(id); } - return mapRowToProduct(row); + return await mapRowToProduct(row); } export async function getAdminProductPrices( @@ -88,5 +88,5 @@ export async function getAdminProductsList( .from(products) .where(conditions.length ? and(...conditions) : undefined); - return rows.map(mapRowToProduct); + return await mapRowsToProducts(rows); } diff --git a/frontend/lib/services/products/images.ts b/frontend/lib/services/products/images.ts new file mode 100644 index 00000000..f8955d75 --- /dev/null +++ b/frontend/lib/services/products/images.ts @@ -0,0 +1,146 @@ +import { asc, eq, inArray } from 'drizzle-orm'; + +import { db } from '@/db'; +import { productImages } from '@/db/schema'; +import { type ProductImage, productImageSchema } from '@/lib/validation/shop'; + +type ProductImagesReader = Pick; + +type ProductImageCompatibleRow = { + id: string; + imageUrl: string; + imagePublicId: string | null; + createdAt: Date; + updatedAt: Date; +}; + +export type ResolvedProductImages = { + images: ProductImage[]; + primaryImage?: ProductImage; + imageUrl: string; + imagePublicId?: string; +}; + +function buildLegacyProductImage( + row: ProductImageCompatibleRow +): ProductImage | null { + const imageUrl = row.imageUrl.trim(); + if (!imageUrl) return null; + + return productImageSchema.parse({ + id: `legacy:${row.id}`, + productId: row.id, + imageUrl, + imagePublicId: row.imagePublicId ?? undefined, + sortOrder: 0, + isPrimary: true, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); +} + +export async function getProductImagesByProductIds( + productIds: string[], + options?: { db?: ProductImagesReader } +): Promise> { + const uniqueProductIds = Array.from( + new Set(productIds.filter(id => typeof id === 'string' && id.trim().length)) + ); + + const byProductId = new Map(); + if (uniqueProductIds.length === 0) return byProductId; + + const executor = options?.db ?? db; + const rows = await executor + .select({ + id: productImages.id, + productId: productImages.productId, + imageUrl: productImages.imageUrl, + imagePublicId: productImages.imagePublicId, + sortOrder: productImages.sortOrder, + isPrimary: productImages.isPrimary, + createdAt: productImages.createdAt, + updatedAt: productImages.updatedAt, + }) + .from(productImages) + .where(inArray(productImages.productId, uniqueProductIds)) + .orderBy( + asc(productImages.sortOrder), + asc(productImages.createdAt), + asc(productImages.id) + ); + + for (const row of rows) { + const parsed = productImageSchema.parse(row); + const group = byProductId.get(parsed.productId); + if (group) group.push(parsed); + else byProductId.set(parsed.productId, [parsed]); + } + + return byProductId; +} + +export async function getProductImagesByProductId( + productId: string, + options?: { db?: ProductImagesReader } +): Promise { + const byProductId = await getProductImagesByProductIds([productId], options); + return byProductId.get(productId) ?? []; +} + +export function resolveProductImages( + row: ProductImageCompatibleRow, + storedImages?: ProductImage[] +): ResolvedProductImages { + if (storedImages && storedImages.length > 0) { + const primaryImage = storedImages.find(image => image.isPrimary); + + return { + images: storedImages, + primaryImage, + imageUrl: primaryImage?.imageUrl ?? row.imageUrl, + imagePublicId: + primaryImage?.imagePublicId ?? row.imagePublicId ?? undefined, + }; + } + + const legacyImage = buildLegacyProductImage(row); + return { + images: legacyImage ? [legacyImage] : [], + primaryImage: legacyImage ?? undefined, + imageUrl: legacyImage?.imageUrl ?? row.imageUrl, + imagePublicId: legacyImage?.imagePublicId ?? row.imagePublicId ?? undefined, + }; +} + +export async function getPrimaryProductImageRow( + productId: string, + options?: { db?: ProductImagesReader } +): Promise { + const executor = options?.db ?? db; + const rows = await executor + .select({ + id: productImages.id, + productId: productImages.productId, + imageUrl: productImages.imageUrl, + imagePublicId: productImages.imagePublicId, + sortOrder: productImages.sortOrder, + isPrimary: productImages.isPrimary, + createdAt: productImages.createdAt, + updatedAt: productImages.updatedAt, + }) + .from(productImages) + .where(eq(productImages.productId, productId)) + .orderBy( + asc(productImages.sortOrder), + asc(productImages.createdAt), + asc(productImages.id) + ); + + for (const row of rows) { + const parsed = productImageSchema.parse(row); + if (parsed.isPrimary) return parsed; + } + + return null; +} diff --git a/frontend/lib/services/products/mapping.ts b/frontend/lib/services/products/mapping.ts index fb461c6a..198066ba 100644 --- a/frontend/lib/services/products/mapping.ts +++ b/frontend/lib/services/products/mapping.ts @@ -1,9 +1,13 @@ import { fromCents, fromDbMoney } from '@/lib/shop/money'; import type { DbProduct } from '@/lib/types/shop'; +import { getProductImagesByProductIds, resolveProductImages } from './images'; import type { ProductRow } from './types'; -export function mapRowToProduct(row: ProductRow): DbProduct { +function mapRowToProductWithResolvedImages( + row: ProductRow, + resolvedImages: ReturnType +): DbProduct { const priceCents = fromDbMoney(row.price); const originalPriceCents = row.originalPrice == null ? undefined : fromDbMoney(row.originalPrice); @@ -11,12 +15,40 @@ export function mapRowToProduct(row: ProductRow): DbProduct { return { ...row, description: row.description ?? undefined, + imageUrl: resolvedImages.imageUrl, + imagePublicId: resolvedImages.imagePublicId ?? undefined, price: fromCents(priceCents), originalPrice: originalPriceCents == null ? undefined : fromCents(originalPriceCents), - imagePublicId: row.imagePublicId ?? undefined, + images: resolvedImages.images, + primaryImage: resolvedImages.primaryImage, sku: row.sku ?? undefined, category: row.category ?? undefined, type: row.type ?? undefined, }; } + +export async function mapRowToProduct(row: ProductRow): Promise { + const imagesByProductId = await getProductImagesByProductIds([row.id]); + return mapRowToProductWithResolvedImages( + row, + resolveProductImages(row, imagesByProductId.get(row.id)) + ); +} + +export async function mapRowsToProducts( + rows: ProductRow[] +): Promise { + if (rows.length === 0) return []; + + const imagesByProductId = await getProductImagesByProductIds( + rows.map(row => row.id) + ); + + return rows.map(row => + mapRowToProductWithResolvedImages( + row, + resolveProductImages(row, imagesByProductId.get(row.id)) + ) + ); +} diff --git a/frontend/lib/services/products/mutations/create.ts b/frontend/lib/services/products/mutations/create.ts index 2c5c3e95..78512b40 100644 --- a/frontend/lib/services/products/mutations/create.ts +++ b/frontend/lib/services/products/mutations/create.ts @@ -1,8 +1,9 @@ -import { eq } from 'drizzle-orm'; - import { db } from '@/db'; -import { productPrices, products } from '@/db/schema'; -import { uploadProductImageFromFile } from '@/lib/cloudinary'; +import { productImages, productPrices, products } from '@/db/schema'; +import { + destroyProductImage, + uploadProductImageFromFile, +} from '@/lib/cloudinary'; import { logError } from '@/lib/logging'; import { toDbMoney } from '@/lib/shop/money'; import type { DbProduct, ProductInput } from '@/lib/types/shop'; @@ -17,15 +18,9 @@ import { } from '../prices'; import { normalizeSlug } from '../slug'; -type ProductMutationExecutor = Pick; - -export async function createProduct( - input: ProductInput, - options?: { db?: ProductMutationExecutor } -): Promise { - const executor = options?.db ?? db; +export async function createProduct(input: ProductInput): Promise { const slug = await normalizeSlug( - executor, + db, (input as any).slug ?? (input as any).title ); @@ -50,72 +45,81 @@ export async function createProduct( const usd = requireUsd(prices); - let createdProductId: string | null = null; - try { - const [row] = await executor - .insert(products) - .values({ - slug, - title: (input as any).title, - description: (input as any).description ?? null, - imageUrl: uploaded?.secureUrl ?? '', - imagePublicId: uploaded?.publicId, - price: toDbMoney(usd.priceMinor), - originalPrice: - usd.originalPriceMinor == null - ? null - : toDbMoney(usd.originalPriceMinor), - currency: 'USD', - - category: (input as any).category ?? null, - type: (input as any).type ?? null, - colors: (input as any).colors ?? [], - sizes: (input as any).sizes ?? [], - badge: (input as any).badge ?? 'NONE', - isActive: (input as any).isActive ?? true, - isFeatured: (input as any).isFeatured ?? false, - stock: (input as any).stock ?? 0, - sku: (input as any).sku ?? null, - }) - .onConflictDoNothing({ target: products.slug }) - .returning(); - - if (!row) { - throw new SlugConflictError('Slug already exists.'); - } - - createdProductId = row.id; + const row = await db.transaction(async tx => { + const [inserted] = await tx + .insert(products) + .values({ + slug, + title: (input as any).title, + description: (input as any).description ?? null, + imageUrl: uploaded?.secureUrl ?? '', + imagePublicId: uploaded?.publicId, + price: toDbMoney(usd.priceMinor), + originalPrice: + usd.originalPriceMinor == null + ? null + : toDbMoney(usd.originalPriceMinor), + currency: 'USD', + + category: (input as any).category ?? null, + type: (input as any).type ?? null, + colors: (input as any).colors ?? [], + sizes: (input as any).sizes ?? [], + badge: (input as any).badge ?? 'NONE', + isActive: (input as any).isActive ?? true, + isFeatured: (input as any).isFeatured ?? false, + stock: (input as any).stock ?? 0, + sku: (input as any).sku ?? null, + }) + .onConflictDoNothing({ target: products.slug }) + .returning(); + + if (!inserted) { + throw new SlugConflictError('Slug already exists.'); + } - await executor.insert(productPrices).values( - prices.map(p => { - const priceMinor = p.priceMinor; - const originalMinor = p.originalPriceMinor; + await tx.insert(productPrices).values( + prices.map(p => { + const priceMinor = p.priceMinor; + const originalMinor = p.originalPriceMinor; + + return { + productId: inserted.id, + currency: p.currency, + priceMinor, + originalPriceMinor: originalMinor, + price: toDbMoney(priceMinor), + originalPrice: + originalMinor == null ? null : toDbMoney(originalMinor), + }; + }) + ); + + await tx.insert(productImages).values({ + productId: inserted.id, + imageUrl: uploaded?.secureUrl ?? '', + imagePublicId: uploaded?.publicId ?? null, + sortOrder: 0, + isPrimary: true, + }); - return { - productId: row.id, - currency: p.currency, - priceMinor, - originalPriceMinor: originalMinor, - price: toDbMoney(priceMinor), - originalPrice: - originalMinor == null ? null : toDbMoney(originalMinor), - }; - }) - ); + return inserted; + }); - return mapRowToProduct(row); + return await mapRowToProduct(row); } catch (error) { - if (createdProductId && !options?.db) { + if (uploaded?.publicId) { try { - await db.delete(products).where(eq(products.id, createdProductId)); - } catch (cleanupDbError) { + await destroyProductImage(uploaded.publicId); + } catch (cleanupError) { logError( - 'Failed to cleanup product after create failure', - cleanupDbError + 'Failed to cleanup uploaded image after create failure', + cleanupError ); } } + throw error; } } diff --git a/frontend/lib/services/products/mutations/delete.ts b/frontend/lib/services/products/mutations/delete.ts index c265d47e..6456484e 100644 --- a/frontend/lib/services/products/mutations/delete.ts +++ b/frontend/lib/services/products/mutations/delete.ts @@ -1,43 +1,60 @@ -import { sql } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { db } from '@/db'; -import { productPrices, products } from '@/db/schema'; +import { productImages, productPrices, products } from '@/db/schema'; import { destroyProductImage } from '@/lib/cloudinary'; import { ProductNotFoundError } from '@/lib/errors/products'; import { logError } from '@/lib/logging'; export async function deleteProduct(id: string): Promise { - const result = await db.execute(sql` - WITH del_prices AS ( - DELETE FROM ${productPrices} - WHERE ${productPrices.productId} = ${id} - ), - del_product AS ( - DELETE FROM ${products} - WHERE ${products.id} = ${id} - RETURNING ${products.id} AS id, ${products.imagePublicId} AS imagePublicId - ) - SELECT id, imagePublicId FROM del_product; - `); - - const rows = - ( - result as unknown as { - rows?: Array<{ id: string; imagePublicId: string | null }>; - } - ).rows ?? []; - - const [deleted] = rows; - - if (!deleted) { - throw new ProductNotFoundError(id); - } + const { deletedProduct, publicIds } = await db.transaction(async tx => { + const [existingProduct] = await tx + .select({ + id: products.id, + imagePublicId: products.imagePublicId, + }) + .from(products) + .where(eq(products.id, id)) + .limit(1); + + if (!existingProduct) { + throw new ProductNotFoundError(id); + } + + const imageRows = await tx + .select({ imagePublicId: productImages.imagePublicId }) + .from(productImages) + .where(eq(productImages.productId, id)); + + await tx.delete(productPrices).where(eq(productPrices.productId, id)); + await tx.delete(products).where(eq(products.id, id)); + + const publicIds = Array.from( + new Set( + [ + existingProduct.imagePublicId, + ...imageRows.map(row => row.imagePublicId), + ].filter( + (value): value is string => + typeof value === 'string' && value.trim().length > 0 + ) + ) + ); + + return { + deletedProduct: existingProduct, + publicIds, + }; + }); - if (deleted.imagePublicId) { + for (const publicId of publicIds) { try { - await destroyProductImage(deleted.imagePublicId); + await destroyProductImage(publicId); } catch (error) { - logError('Failed to cleanup product image after delete', error); + logError('Failed to cleanup product image after delete', error, { + productId: deletedProduct.id, + imagePublicId: publicId, + }); } } } diff --git a/frontend/lib/services/products/mutations/toggle.ts b/frontend/lib/services/products/mutations/toggle.ts index 8bde76d1..1e38aa28 100644 --- a/frontend/lib/services/products/mutations/toggle.ts +++ b/frontend/lib/services/products/mutations/toggle.ts @@ -28,5 +28,5 @@ export async function toggleProductStatus(id: string): Promise { throw new ProductNotFoundError(id); } - return mapRowToProduct(updated); + return await mapRowToProduct(updated); } diff --git a/frontend/lib/services/products/mutations/update.ts b/frontend/lib/services/products/mutations/update.ts index c9abfa8d..400b5e95 100644 --- a/frontend/lib/services/products/mutations/update.ts +++ b/frontend/lib/services/products/mutations/update.ts @@ -1,7 +1,7 @@ import { eq, sql } from 'drizzle-orm'; import { db } from '@/db'; -import { productPrices, products } from '@/db/schema'; +import { productImages, productPrices, products } from '@/db/schema'; import { destroyProductImage, uploadProductImageFromFile, @@ -13,6 +13,7 @@ import { toDbMoney } from '@/lib/shop/money'; import type { DbProduct, ProductUpdateInput } from '@/lib/types/shop'; import { SlugConflictError } from '../../errors'; +import { getProductImagesByProductId } from '../images'; import { mapRowToProduct } from '../mapping'; import { assertMergedPricesPolicy, @@ -38,6 +39,10 @@ export async function updateProduct( throw new ProductNotFoundError(id); } + const existingImages = await getProductImagesByProductId(id); + const currentPrimaryImage = + existingImages.find(image => image.isPrimary) ?? null; + const slug = await normalizeSlug( db, (input as any).slug ?? (input as any).title ?? existing.slug, @@ -104,12 +109,18 @@ export async function updateProduct( } } + const mirroredImageUrl = currentPrimaryImage?.imageUrl ?? existing.imageUrl; + const mirroredImagePublicId = + currentPrimaryImage?.imagePublicId ?? existing.imagePublicId ?? undefined; + const updateData: Partial = { slug, title: (input as any).title ?? existing.title, description: (input as any).description ?? existing.description ?? null, - imageUrl: uploaded ? uploaded.secureUrl : existing.imageUrl, - imagePublicId: uploaded ? uploaded.publicId : existing.imagePublicId, + imageUrl: uploaded ? uploaded.secureUrl : mirroredImageUrl, + imagePublicId: uploaded + ? uploaded.publicId + : (mirroredImagePublicId ?? null), category: (input as any).category ?? existing.category, type: (input as any).type ?? existing.type, @@ -130,56 +141,94 @@ export async function updateProduct( // product_prices is the single write-authority for catalog pricing. try { - if (prices.length) { - const upsertRows = prices.map(p => { - const priceMinor = p.priceMinor; - const originalMinor = p.originalPriceMinor; - - return { - productId: id, - currency: p.currency, - priceMinor, - originalPriceMinor: originalMinor, - price: toDbMoney(priceMinor), - originalPrice: - originalMinor == null ? null : toDbMoney(originalMinor), - }; - }); - - await db - .insert(productPrices) - .values(upsertRows) - .onConflictDoUpdate({ - target: [productPrices.productId, productPrices.currency], - set: { - priceMinor: sql`excluded.price_minor`, - originalPriceMinor: sql`excluded.original_price_minor`, - price: sql`excluded.price`, - originalPrice: sql`excluded.original_price`, - updatedAt: sql`now()`, - }, + const row = await db.transaction(async tx => { + if (prices.length) { + const upsertRows = prices.map(p => { + const priceMinor = p.priceMinor; + const originalMinor = p.originalPriceMinor; + + return { + productId: id, + currency: p.currency, + priceMinor, + originalPriceMinor: originalMinor, + price: toDbMoney(priceMinor), + originalPrice: + originalMinor == null ? null : toDbMoney(originalMinor), + }; }); - } - const [row] = await db - .update(products) - .set(updateData) - .where(eq(products.id, id)) - .returning(); + await tx + .insert(productPrices) + .values(upsertRows) + .onConflictDoUpdate({ + target: [productPrices.productId, productPrices.currency], + set: { + priceMinor: sql`excluded.price_minor`, + originalPriceMinor: sql`excluded.original_price_minor`, + price: sql`excluded.price`, + originalPrice: sql`excluded.original_price`, + updatedAt: sql`now()`, + }, + }); + } + + if (uploaded) { + if (currentPrimaryImage) { + await tx + .update(productImages) + .set({ + imageUrl: uploaded.secureUrl, + imagePublicId: uploaded.publicId, + updatedAt: new Date(), + }) + .where(eq(productImages.id, currentPrimaryImage.id)); + } else { + const nextSortOrder = + existingImages.reduce( + (maxSortOrder, image) => Math.max(maxSortOrder, image.sortOrder), + -1 + ) + 1; + + await tx.insert(productImages).values({ + productId: id, + imageUrl: uploaded.secureUrl, + imagePublicId: uploaded.publicId, + sortOrder: nextSortOrder, + isPrimary: true, + }); + } + } + + const [updatedRow] = await tx + .update(products) + .set(updateData) + .where(eq(products.id, id)) + .returning(); - if (!row) { - throw new ProductNotFoundError(id); - } + if (!updatedRow) { + throw new ProductNotFoundError(id); + } + + return updatedRow; + }); + + const replacedPrimaryPublicId = + currentPrimaryImage?.imagePublicId ?? existing.imagePublicId ?? null; - if (uploaded && existing.imagePublicId) { + if ( + uploaded && + replacedPrimaryPublicId && + replacedPrimaryPublicId !== uploaded.publicId + ) { try { - await destroyProductImage(existing.imagePublicId); + await destroyProductImage(replacedPrimaryPublicId); } catch (cleanupError) { logError('Failed to cleanup old image after update', cleanupError); } } - return mapRowToProduct(row); + return await mapRowToProduct(row); } catch (error) { if (uploaded?.publicId) { try { diff --git a/frontend/lib/shop/data.ts b/frontend/lib/shop/data.ts index 120553ae..cc3c1b63 100644 --- a/frontend/lib/shop/data.ts +++ b/frontend/lib/shop/data.ts @@ -17,6 +17,7 @@ import { dbProductSchema, type ProductBadge, productBadgeValues, + type ProductImage, type ShopProduct as ValidationShopProduct, shopProductSchema, } from '@/lib/validation/shop'; @@ -109,6 +110,16 @@ export class CatalogValidationError extends Error { const placeholderImage = '/placeholder.svg'; +function mapToShopProductImage(image: ProductImage) { + return { + id: image.id, + url: image.imageUrl || placeholderImage, + publicId: image.imagePublicId, + sortOrder: image.sortOrder, + isPrimary: image.isPrimary, + }; +} + function deriveStock(product: DbProduct): boolean { if (!product.isActive) return false; return product.stock > 0; @@ -167,7 +178,14 @@ function mapToShopProduct(product: DbProduct): ShopProduct | null { name: validated.title, price: fromDbMoney(validated.price), currency: validated.currency, - image: validated.imageUrl || placeholderImage, + image: + validated.primaryImage?.imageUrl || + validated.imageUrl || + placeholderImage, + images: validated.images.map(mapToShopProductImage), + primaryImage: validated.primaryImage + ? mapToShopProductImage(validated.primaryImage) + : undefined, originalPrice: validated.originalPrice ? fromDbMoney(validated.originalPrice) : undefined, diff --git a/frontend/lib/tests/shop/product-images-contract.test.ts b/frontend/lib/tests/shop/product-images-contract.test.ts new file mode 100644 index 00000000..8d91da66 --- /dev/null +++ b/frontend/lib/tests/shop/product-images-contract.test.ts @@ -0,0 +1,314 @@ +import { randomUUID } from 'node:crypto'; + +import { asc, eq } from 'drizzle-orm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const cloudinaryMocks = vi.hoisted(() => ({ + uploadProductImageFromFile: vi.fn(), + destroyProductImage: vi.fn(), +})); + +vi.mock('@/lib/cloudinary', () => cloudinaryMocks); + +import { db } from '@/db'; +import { getPublicProductBySlug } from '@/db/queries/shop/products'; +import { productImages, productPrices, products } from '@/db/schema'; +import { createProduct, updateProduct } from '@/lib/services/products'; +import { toDbMoney } from '@/lib/shop/money'; + +async function cleanupProduct(productId: string) { + await db.delete(products).where(eq(products.id, productId)); +} + +describe.sequential('product images contract', () => { + const createdProductIds: string[] = []; + + afterEach(async () => { + vi.clearAllMocks(); + + for (const productId of createdProductIds.splice(0)) { + await cleanupProduct(productId); + } + }); + + it('preserves legacy single-image products by synthesizing a primary image contract', async () => { + const productId = randomUUID(); + const slug = `legacy-product-${randomUUID()}`; + createdProductIds.push(productId); + + await db.insert(products).values({ + id: productId, + slug, + title: 'Legacy product', + description: null, + imageUrl: 'https://example.com/legacy-primary.png', + imagePublicId: 'products/legacy-primary', + price: toDbMoney(2500), + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 3, + sku: null, + }); + + await db.insert(productPrices).values({ + productId, + currency: 'USD', + priceMinor: 2500, + originalPriceMinor: null, + price: toDbMoney(2500), + originalPrice: null, + }); + + const product = await getPublicProductBySlug(slug, 'USD'); + + expect(product).not.toBeNull(); + expect(product?.imageUrl).toBe('https://example.com/legacy-primary.png'); + expect(product?.primaryImage?.imageUrl).toBe( + 'https://example.com/legacy-primary.png' + ); + expect(product?.primaryImage?.isPrimary).toBe(true); + expect(product?.images).toHaveLength(1); + expect(product?.images[0]?.id).toBe(`legacy:${productId}`); + }); + + it('hydrates ordered image collections and explicit primary image from product_images', async () => { + const productId = randomUUID(); + const slug = `gallery-product-${randomUUID()}`; + createdProductIds.push(productId); + + await db.insert(products).values({ + id: productId, + slug, + title: 'Gallery product', + description: null, + imageUrl: 'https://example.com/stale-legacy.png', + imagePublicId: 'products/stale-legacy', + price: toDbMoney(3400), + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 8, + sku: null, + }); + + await db.insert(productPrices).values({ + productId, + currency: 'USD', + priceMinor: 3400, + originalPriceMinor: null, + price: toDbMoney(3400), + originalPrice: null, + }); + + await db.insert(productImages).values([ + { + productId, + imageUrl: 'https://example.com/gallery-secondary.png', + imagePublicId: 'products/gallery-secondary', + sortOrder: 20, + isPrimary: false, + }, + { + productId, + imageUrl: 'https://example.com/gallery-primary.png', + imagePublicId: 'products/gallery-primary', + sortOrder: 10, + isPrimary: true, + }, + { + productId, + imageUrl: 'https://example.com/gallery-third.png', + imagePublicId: 'products/gallery-third', + sortOrder: 30, + isPrimary: false, + }, + ]); + + const product = await getPublicProductBySlug(slug, 'USD'); + + expect(product).not.toBeNull(); + expect(product?.imageUrl).toBe('https://example.com/gallery-primary.png'); + expect(product?.imagePublicId).toBe('products/gallery-primary'); + expect(product?.primaryImage?.imageUrl).toBe( + 'https://example.com/gallery-primary.png' + ); + expect(product?.images.map(image => image.imageUrl)).toEqual([ + 'https://example.com/gallery-primary.png', + 'https://example.com/gallery-secondary.png', + 'https://example.com/gallery-third.png', + ]); + }); + + it('createProduct writes a primary product_images row and returns the expanded image contract', async () => { + cloudinaryMocks.uploadProductImageFromFile.mockResolvedValueOnce({ + secureUrl: 'https://example.com/create-primary.png', + publicId: 'products/create-primary', + }); + + const created = await createProduct({ + title: `Created product ${randomUUID()}`, + image: new File([new Uint8Array([1, 2, 3])], 'create.png', { + type: 'image/png', + }), + prices: [{ currency: 'USD', priceMinor: 4100, originalPriceMinor: null }], + badge: 'NONE', + stock: 4, + isActive: true, + isFeatured: false, + } as any); + + createdProductIds.push(created.id); + + const imageRows = await db + .select({ + imageUrl: productImages.imageUrl, + imagePublicId: productImages.imagePublicId, + sortOrder: productImages.sortOrder, + isPrimary: productImages.isPrimary, + }) + .from(productImages) + .where(eq(productImages.productId, created.id)) + .orderBy(asc(productImages.sortOrder)); + + expect(created.imageUrl).toBe('https://example.com/create-primary.png'); + expect(created.primaryImage?.imageUrl).toBe( + 'https://example.com/create-primary.png' + ); + expect(created.images).toHaveLength(1); + expect(imageRows).toEqual([ + { + imageUrl: 'https://example.com/create-primary.png', + imagePublicId: 'products/create-primary', + sortOrder: 0, + isPrimary: true, + }, + ]); + }); + + it('updateProduct replaces only the explicit primary image and preserves the rest of the gallery', async () => { + const productId = randomUUID(); + const slug = `update-gallery-${randomUUID()}`; + createdProductIds.push(productId); + + await db.insert(products).values({ + id: productId, + slug, + title: 'Update gallery product', + description: null, + imageUrl: 'https://example.com/old-primary.png', + imagePublicId: 'products/old-primary', + price: toDbMoney(5400), + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 9, + sku: null, + }); + + await db.insert(productPrices).values({ + productId, + currency: 'USD', + priceMinor: 5400, + originalPriceMinor: null, + price: toDbMoney(5400), + originalPrice: null, + }); + + await db.insert(productImages).values([ + { + productId, + imageUrl: 'https://example.com/old-primary.png', + imagePublicId: 'products/old-primary', + sortOrder: 1, + isPrimary: true, + }, + { + productId, + imageUrl: 'https://example.com/secondary.png', + imagePublicId: 'products/secondary', + sortOrder: 2, + isPrimary: false, + }, + ]); + + cloudinaryMocks.uploadProductImageFromFile.mockResolvedValueOnce({ + secureUrl: 'https://example.com/new-primary.png', + publicId: 'products/new-primary', + }); + + const updated = await updateProduct(productId, { + title: 'Updated gallery product', + image: new File([new Uint8Array([5, 6, 7])], 'updated.png', { + type: 'image/png', + }), + }); + + const imageRows = await db + .select({ + imageUrl: productImages.imageUrl, + imagePublicId: productImages.imagePublicId, + sortOrder: productImages.sortOrder, + isPrimary: productImages.isPrimary, + }) + .from(productImages) + .where(eq(productImages.productId, productId)) + .orderBy(asc(productImages.sortOrder)); + + const [productRow] = await db + .select({ + imageUrl: products.imageUrl, + imagePublicId: products.imagePublicId, + }) + .from(products) + .where(eq(products.id, productId)) + .limit(1); + + expect(updated.primaryImage?.imageUrl).toBe( + 'https://example.com/new-primary.png' + ); + expect(updated.images.map(image => image.imageUrl)).toEqual([ + 'https://example.com/new-primary.png', + 'https://example.com/secondary.png', + ]); + expect(imageRows).toEqual([ + { + imageUrl: 'https://example.com/new-primary.png', + imagePublicId: 'products/new-primary', + sortOrder: 1, + isPrimary: true, + }, + { + imageUrl: 'https://example.com/secondary.png', + imagePublicId: 'products/secondary', + sortOrder: 2, + isPrimary: false, + }, + ]); + expect(productRow).toEqual({ + imageUrl: 'https://example.com/new-primary.png', + imagePublicId: 'products/new-primary', + }); + expect(cloudinaryMocks.destroyProductImage).toHaveBeenCalledWith( + 'products/old-primary' + ); + }); +}); diff --git a/frontend/lib/types/shop.ts b/frontend/lib/types/shop.ts index d4347438..441e7b39 100644 --- a/frontend/lib/types/shop.ts +++ b/frontend/lib/types/shop.ts @@ -10,6 +10,7 @@ import { paymentStatusSchema, productAdminSchema, productAdminUpdateSchema, + productImageSchema, } from '@/lib/validation/shop'; export type AdminProductPayload = z.infer; @@ -21,6 +22,7 @@ export type ProductUpdateInput = z.infer & { prices?: ProductPriceInput[]; }; +export type ProductImage = z.infer; export type DbProduct = z.infer; export type CheckoutItem = z.infer; diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts index 0ce21386..3bf2e8e5 100644 --- a/frontend/lib/validation/shop.ts +++ b/frontend/lib/validation/shop.ts @@ -104,6 +104,20 @@ export const catalogFilterSchema = z }) .strict(); +export const productImageSchema = z.object({ + id: z.string(), + productId: z.string(), + imageUrl: z.string(), + imagePublicId: z + .string() + .nullish() + .transform(value => value ?? undefined), + sortOrder: z.coerce.number().int().min(0), + isPrimary: z.boolean(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), +}); + export const dbProductSchema = z.object({ id: z.string(), slug: z.string(), @@ -149,10 +163,25 @@ export const dbProductSchema = z.object({ badge: badgeSchema .nullish() .transform(value => (value ?? 'NONE') as ProductBadge), + images: z.array(productImageSchema).default([]), + primaryImage: productImageSchema + .nullish() + .transform(value => value ?? undefined), createdAt: z.coerce.date(), updatedAt: z.coerce.date(), }); +export const shopProductImageSchema = z.object({ + id: z.string(), + url: z.string(), + publicId: z + .string() + .nullish() + .transform(value => value ?? undefined), + sortOrder: z.coerce.number().int().min(0), + isPrimary: z.boolean(), +}); + export const shopProductSchema = z.object({ id: z.string(), slug: z.string(), @@ -160,6 +189,10 @@ export const shopProductSchema = z.object({ price: z.number().int().min(0), currency: currencySchema, image: z.string(), + images: z.array(shopProductImageSchema).default([]), + primaryImage: shopProductImageSchema + .nullish() + .transform(value => value ?? undefined), originalPrice: z.number().int().min(0).optional(), createdAt: z.coerce.date().optional(), category: z.enum(productCategoryValues as [string, ...string[]]).optional(), @@ -621,7 +654,9 @@ export const orderPaymentInitPayloadSchema = z export type CatalogQuery = z.infer; export type CatalogFilters = z.infer; +export type ProductImage = z.infer; export type DbProduct = z.infer; +export type ShopProductImage = z.infer; export type ShopProduct = z.infer; export type OrderSummary = z.infer; export type ProductAdminInput = z.infer; From c5ce5fd7c109d70a46205a2819bb26f9aeae6305 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 25 Mar 2026 11:54:39 -0700 Subject: [PATCH 02/12] (SP: 2)[SHOP] add admin multi-image product photo management --- .../admin/shop/products/[id]/edit/page.tsx | 27 +- .../shop/products/_components/ProductForm.tsx | 282 +++++++++++++++-- .../app/api/shop/admin/products/[id]/route.ts | 37 ++- frontend/app/api/shop/admin/products/route.ts | 74 +++-- frontend/lib/admin/parseAdminProductForm.ts | 182 +++++++++++ .../lib/services/products/mutations/create.ts | 146 +++++++-- .../lib/services/products/mutations/update.ts | 191 +++++++++++- frontend/lib/services/products/photo-plan.ts | 130 ++++++++ ...min-product-canonical-audit-phase5.test.ts | 24 +- ...admin-product-create-atomic-phasec.test.ts | 10 + ...-patch-price-config-error-contract.test.ts | 4 + .../admin-product-photo-management.test.ts | 293 ++++++++++++++++++ .../shop/admin-product-sale-contract.test.ts | 28 +- frontend/lib/types/shop.ts | 15 +- frontend/lib/validation/shop.ts | 78 +++++ 15 files changed, 1415 insertions(+), 106 deletions(-) create mode 100644 frontend/lib/services/products/photo-plan.ts create mode 100644 frontend/lib/tests/shop/admin-product-photo-management.test.ts diff --git a/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx b/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx index 4b7b5cda..d8316731 100644 --- a/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx +++ b/frontend/app/[locale]/admin/shop/products/[id]/edit/page.tsx @@ -1,11 +1,9 @@ -import { eq } from 'drizzle-orm'; import { Metadata } from 'next'; import { notFound } from 'next/navigation'; import { z } from 'zod'; -import { db } from '@/db'; -import { productPrices, products } from '@/db/schema'; import { issueCsrfToken } from '@/lib/security/csrf'; +import { getAdminProductByIdWithPrices } from '@/lib/services/products'; import type { CurrencyCode } from '@/lib/shop/currency'; import { currencyValues } from '@/lib/shop/currency'; @@ -37,22 +35,14 @@ export default async function EditProductPage({ const parsed = paramsSchema.safeParse(rawParams); if (!parsed.success) notFound(); - const [product] = await db - .select() - .from(products) - .where(eq(products.id, parsed.data.id)) - .limit(1); - - if (!product) notFound(); + let product; + try { + product = await getAdminProductByIdWithPrices(parsed.data.id); + } catch { + notFound(); + } - const prices = await db - .select({ - currency: productPrices.currency, - price: productPrices.price, - originalPrice: productPrices.originalPrice, - }) - .from(productPrices) - .where(eq(productPrices.productId, product.id)); + const prices = product.prices; const initialPrices = prices.length ? prices @@ -98,6 +88,7 @@ export default async function EditProductPage({ stock: product.stock, sku: product.sku ?? undefined, imageUrl: product.imageUrl, + images: product.images, }} /> diff --git a/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx b/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx index c2ffb546..7d085498 100644 --- a/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx +++ b/frontend/app/[locale]/admin/shop/products/_components/ProductForm.tsx @@ -1,11 +1,12 @@ 'use client'; +import Image from 'next/image'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useRouter } from '@/i18n/routing'; import { CATEGORIES, COLORS, PRODUCT_TYPES, SIZES } from '@/lib/config/catalog'; import { logError } from '@/lib/logging'; -import type { ProductAdminInput } from '@/lib/validation/shop'; +import type { ProductAdminInput, ProductImage } from '@/lib/validation/shop'; const localSlugify = (input: string): string => { return input @@ -20,7 +21,10 @@ const localSlugify = (input: string): string => { type ProductFormProps = { mode: 'create' | 'edit'; productId?: string; - initialValues?: Partial & { imageUrl?: string }; + initialValues?: Partial & { + imageUrl?: string; + images?: ProductImage[]; + }; csrfToken: string; }; @@ -42,6 +46,17 @@ type UiPriceRow = { originalPrice: string; }; +type UiPhoto = { + key: string; + source: 'existing' | 'new'; + imageId?: string; + uploadId?: string; + previewUrl: string; + publicId?: string; + isPrimary: boolean; + file?: File; +}; + type SaleRuleDetails = { currency?: CurrencyCode; field?: string; @@ -125,6 +140,54 @@ function ensureUiPriceRows(fromInitial: unknown): UiPriceRow[] { }); } +function normalizeUiPhotos(photos: UiPhoto[]): UiPhoto[] { + if (photos.length === 0) return []; + + const primaryIndex = photos.findIndex(photo => photo.isPrimary); + const effectivePrimaryIndex = primaryIndex >= 0 ? primaryIndex : 0; + + return photos.map((photo, index) => ({ + ...photo, + isPrimary: index === effectivePrimaryIndex, + })); +} + +function ensureUiPhotos(fromInitial: { + images?: ProductImage[]; + imageUrl?: string; +}): UiPhoto[] { + const explicitImages = Array.isArray(fromInitial.images) + ? [...fromInitial.images] + .sort((a, b) => a.sortOrder - b.sortOrder) + .map(image => ({ + key: `existing:${image.id}`, + source: 'existing' as const, + imageId: image.id, + previewUrl: image.imageUrl, + publicId: image.imagePublicId, + isPrimary: image.isPrimary, + })) + : []; + + if (explicitImages.length > 0) { + return normalizeUiPhotos(explicitImages); + } + + if (fromInitial.imageUrl) { + return [ + { + key: 'legacy-image', + source: 'existing', + imageId: 'legacy-image', + previewUrl: fromInitial.imageUrl, + isPrimary: true, + }, + ]; + } + + return []; +} + export function ProductForm({ mode, productId, @@ -150,6 +213,7 @@ export function ProductForm({ const uahOriginalErrorId = `${idBase}-uah-original-error`; const hydratedKeyRef = useRef(null); + const photosRef = useRef([]); const [title, setTitle] = useState(initialValues?.title ?? ''); const [slug, setSlug] = useState( initialValues?.slug @@ -184,8 +248,12 @@ export function ProductForm({ initialValues?.isFeatured ?? false ); - const [imageFile, setImageFile] = useState(null); - const existingImageUrl = initialValues?.imageUrl; + const [photos, setPhotos] = useState( + ensureUiPhotos({ + images: initialValues?.images, + imageUrl: initialValues?.imageUrl, + }) + ); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); @@ -224,7 +292,6 @@ export function ProductForm({ setImageError(null); setOriginalPriceErrors({}); setIsSubmitting(false); - setImageFile(null); if (typeof initialValues.title === 'string') setTitle(initialValues.title); if (typeof initialValues.slug === 'string') @@ -243,9 +310,29 @@ export function ProductForm({ setDescription(initialValues.description ?? ''); setIsActive(initialValues.isActive ?? true); setIsFeatured(initialValues.isFeatured ?? false); + setPhotos( + ensureUiPhotos({ + images: initialValues.images, + imageUrl: initialValues.imageUrl, + }) + ); hydratedKeyRef.current = key; }, [mode, initialValues, productId]); + useEffect(() => { + photosRef.current = photos; + }, [photos]); + + useEffect(() => { + return () => { + photosRef.current.forEach(photo => { + if (photo.source === 'new' && photo.previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(photo.previewUrl); + } + }); + }; + }, []); + const slugValue = useMemo(() => { if (mode === 'edit') return slug; return localSlugify(title); @@ -284,12 +371,64 @@ export function ProductForm({ }); } - const handleImageChange = (event: React.ChangeEvent) => { - const file = event.target.files?.[0] ?? null; - setImageFile(file); + const handlePhotoFilesChange = ( + event: React.ChangeEvent + ) => { + const files = Array.from(event.target.files ?? []); + if (files.length === 0) return; + + const nextPhotos = files + .filter(file => file.size > 0) + .map(file => ({ + key: `new:${crypto.randomUUID()}`, + source: 'new' as const, + uploadId: crypto.randomUUID(), + previewUrl: URL.createObjectURL(file), + isPrimary: false, + file, + })); + + setPhotos(prev => normalizeUiPhotos([...prev, ...nextPhotos])); + setImageError(null); + event.target.value = ''; + }; + + const setPrimaryPhoto = (key: string) => { + setPhotos(prev => + prev.map(photo => ({ + ...photo, + isPrimary: photo.key === key, + })) + ); setImageError(null); }; + const movePhoto = (key: string, direction: -1 | 1) => { + setPhotos(prev => { + const index = prev.findIndex(photo => photo.key === key); + if (index < 0) return prev; + + const nextIndex = index + direction; + if (nextIndex < 0 || nextIndex >= prev.length) return prev; + + const next = [...prev]; + const [photo] = next.splice(index, 1); + next.splice(nextIndex, 0, photo); + return normalizeUiPhotos(next); + }); + }; + + const removePhoto = (key: string) => { + setPhotos(prev => { + const removed = prev.find(photo => photo.key === key); + if (removed?.source === 'new' && removed.previewUrl.startsWith('blob:')) { + URL.revokeObjectURL(removed.previewUrl); + } + + return normalizeUiPhotos(prev.filter(photo => photo.key !== key)); + }); + }; + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); @@ -298,8 +437,8 @@ export function ProductForm({ setImageError(null); setOriginalPriceErrors({}); - if (mode === 'create' && !imageFile) { - setImageError('Image file is required.'); + if (photos.length === 0) { + setImageError('At least one product photo is required.'); return; } @@ -368,9 +507,30 @@ export function ProductForm({ formData.append('isActive', isActive ? 'true' : 'false'); formData.append('isFeatured', isFeatured ? 'true' : 'false'); - if (imageFile) { - formData.append('image', imageFile); - } + const photoPlan = photos.map(photo => ({ + imageId: photo.source === 'existing' ? photo.imageId : undefined, + uploadId: photo.source === 'new' ? photo.uploadId : undefined, + isPrimary: photo.isPrimary, + })); + + const newPhotos = photos.filter( + ( + photo + ): photo is UiPhoto & { source: 'new'; uploadId: string; file: File } => + photo.source === 'new' && + Boolean(photo.uploadId) && + Boolean(photo.file) + ); + + formData.append('photoPlan', JSON.stringify(photoPlan)); + formData.append( + 'newImageUploadIds', + JSON.stringify(newPhotos.map(photo => photo.uploadId)) + ); + newPhotos.forEach(photo => { + formData.append('newImages', photo.file); + }); + if (!csrfToken) { setError('Security token missing. Refresh the page and retry.'); setIsSubmitting(false); @@ -397,8 +557,13 @@ export function ProductForm({ setSlugError('This slug is already used. Try changing the title.'); } - if (data.code === 'IMAGE_UPLOAD_FAILED' || data.field === 'image') { - setImageError(data.error ?? 'Failed to upload image'); + if ( + data.code === 'IMAGE_UPLOAD_FAILED' || + data.code === 'IMAGE_REQUIRED' || + data.field === 'image' || + data.field === 'photos' + ) { + setImageError(data.error ?? 'Failed to update product photos'); } if (data.code === 'SALE_ORIGINAL_REQUIRED') { @@ -916,28 +1081,93 @@ export function ProductForm({ /> -
+
- {existingImageUrl && !imageFile ? ( -

- Current image will be kept unless you upload a new one. -

+

+ Add one or more photos, reorder them, and mark exactly one as + primary. +

+ {photos.length > 0 ? ( +
+ {photos.map((photo, index) => ( +
+ {`Product +
+
+ Photo {index + 1} + {photo.isPrimary ? ( + + Primary + + ) : null} + + {photo.source === 'existing' ? 'Saved' : 'New upload'} + +
+ +
+ + + + +
+
+
+ ))} +
) : null} {imageError ? (

0 - ? imageFile - : undefined, + imagePlan: parsedPhotos.data.imagePlan, + images: parsedPhotos.data.images, }); try { @@ -597,7 +618,7 @@ export async function PATCH( { error: 'Failed to upload product image', code: 'IMAGE_UPLOAD_FAILED', - field: 'image', + field: 'photos', }, { status: 502 } ); diff --git a/frontend/app/api/shop/admin/products/route.ts b/frontend/app/api/shop/admin/products/route.ts index 80744775..3869faff 100644 --- a/frontend/app/api/shop/admin/products/route.ts +++ b/frontend/app/api/shop/admin/products/route.ts @@ -2,7 +2,10 @@ import crypto from 'node:crypto'; import { NextRequest, NextResponse } from 'next/server'; -import { parseAdminProductForm } from '@/lib/admin/parseAdminProductForm'; +import { + parseAdminProductForm, + parseAdminProductPhotosForm, +} from '@/lib/admin/parseAdminProductForm'; import { AdminApiDisabledError, AdminForbiddenError, @@ -160,25 +163,6 @@ export async function POST(request: NextRequest) { return csrfRes; } - const imageFile = formData.get('image'); - if (!(imageFile instanceof File) || imageFile.size === 0) { - logWarn('admin_product_create_image_required', { - ...baseMeta, - code: 'IMAGE_REQUIRED', - slug: slugForLog, - durationMs: Date.now() - startedAtMs, - }); - - return noStoreJson( - { - error: 'Image file is required', - code: 'IMAGE_REQUIRED', - field: 'image', - }, - { status: 400 } - ); - } - const saleViolationFromForm = getSaleViolationFromFormData(formData); if (isInvalidPricesJsonError(saleViolationFromForm)) { logWarn('admin_product_create_invalid_prices_json', { @@ -225,6 +209,9 @@ export async function POST(request: NextRequest) { } const parsed = parseAdminProductForm(formData, { mode: 'create' }); + const parsedPhotos = parseAdminProductPhotosForm(formData, { + mode: 'create', + }); if (!parsed.ok) { const issuesCount = @@ -248,6 +235,48 @@ export async function POST(request: NextRequest) { ); } + if (!parsedPhotos.ok) { + const issuesCount = + ((parsedPhotos.error as any)?.issues?.length as number | undefined) ?? + 0; + + logWarn('admin_product_create_invalid_photos', { + ...baseMeta, + code: 'INVALID_PAYLOAD', + slug: slugForLog, + issuesCount, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: 'Invalid product photos', + code: 'INVALID_PAYLOAD', + field: 'photos', + details: parsedPhotos.error.format(), + }, + { status: 400 } + ); + } + + if (!parsedPhotos.data.imagePlan?.length) { + logWarn('admin_product_create_image_required', { + ...baseMeta, + code: 'IMAGE_REQUIRED', + slug: slugForLog, + durationMs: Date.now() - startedAtMs, + }); + + return noStoreJson( + { + error: 'At least one product photo is required', + code: 'IMAGE_REQUIRED', + field: 'photos', + }, + { status: 400 } + ); + } + const saleViolation = findSaleRuleViolation(parsed.data as any); if (saleViolation) { const message = @@ -284,7 +313,8 @@ export async function POST(request: NextRequest) { try { const inserted = await createProduct({ ...parsed.data, - image: imageFile, + imagePlan: parsedPhotos.data.imagePlan, + images: parsedPhotos.data.images, }); try { @@ -439,7 +469,7 @@ export async function POST(request: NextRequest) { { error: 'Failed to upload product image', code: 'IMAGE_UPLOAD_FAILED', - field: 'image', + field: 'photos', }, { status: 502 } ); diff --git a/frontend/lib/admin/parseAdminProductForm.ts b/frontend/lib/admin/parseAdminProductForm.ts index 4aa6604d..0f68ff2d 100644 --- a/frontend/lib/admin/parseAdminProductForm.ts +++ b/frontend/lib/admin/parseAdminProductForm.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { type CurrencyCode, currencyValues } from '@/lib/shop/currency'; import { toCents } from '@/lib/shop/money'; import { + adminProductPhotoPlanSchema, productAdminSchema, productAdminUpdateSchema, } from '@/lib/validation/shop'; @@ -74,6 +75,19 @@ function zodPricesJsonError(message: string) { ]); } +function zodPhotoError( + message: string, + path: Array = ['photos'] +) { + return new z.ZodError([ + { + code: z.ZodIssueCode.custom, + path, + message, + }, + ]); +} + function parseMajorToMinor( value: unknown, opts: { field: 'price' | 'originalPrice'; currency: string } @@ -347,3 +361,171 @@ export function parseAdminProductForm( return { ok: true, data: parsed.data }; } + +type ParsedAdminProductPhotos = { + imagePlan?: z.infer; + images: Array<{ uploadId: string; file: File }>; +}; + +function parseStringArrayJsonField( + formData: FormData, + name: string +): ParsedResult { + const raw = formData.get(name); + if (raw == null) return { ok: true, data: [] }; + if (typeof raw !== 'string') { + return { + ok: false, + error: zodPhotoError(`Invalid ${name} payload type`, [name]), + }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, error: zodPhotoError(`Invalid ${name} JSON`, [name]) }; + } + + const result = z.array(z.string()).safeParse(parsed); + if (!result.success) { + return { + ok: false, + error: zodPhotoError(`Invalid ${name} payload`, [name]), + }; + } + + return { ok: true, data: result.data }; +} + +export function parseAdminProductPhotosForm( + formData: FormData, + options: { mode?: ParseMode } = {} +): ParsedResult { + const mode: ParseMode = options.mode ?? 'create'; + + const photoPlanRaw = formData.get('photoPlan'); + const legacyImage = formData.get('image'); + + if (photoPlanRaw == null) { + if (!(legacyImage instanceof File) || legacyImage.size === 0) { + return { + ok: true, + data: { + imagePlan: undefined, + images: [], + }, + }; + } + + if (!legacyImage.type?.startsWith('image/')) { + return { + ok: false, + error: zodPhotoError('Uploaded file must be an image', ['photos']), + }; + } + + return { + ok: true, + data: { + imagePlan: [{ uploadId: 'legacy-image', isPrimary: true }], + images: [{ uploadId: 'legacy-image', file: legacyImage }], + }, + }; + } + + if (typeof photoPlanRaw !== 'string') { + return { + ok: false, + error: zodPhotoError('Invalid photoPlan payload type', ['photoPlan']), + }; + } + + let parsedPlanJson: unknown; + try { + parsedPlanJson = JSON.parse(photoPlanRaw); + } catch { + return { + ok: false, + error: zodPhotoError('Invalid photoPlan JSON', ['photoPlan']), + }; + } + + const parsedPlan = adminProductPhotoPlanSchema.safeParse(parsedPlanJson); + if (!parsedPlan.success) { + return { ok: false, error: parsedPlan.error }; + } + + const uploadIdsResult = parseStringArrayJsonField( + formData, + 'newImageUploadIds' + ); + if (!uploadIdsResult.ok) return uploadIdsResult; + + const files = formData.getAll('newImages'); + if (files.length !== uploadIdsResult.data.length) { + return { + ok: false, + error: zodPhotoError( + 'newImages and newImageUploadIds must have the same length', + ['newImages'] + ), + }; + } + + const images: Array<{ uploadId: string; file: File }> = []; + for (let index = 0; index < files.length; index += 1) { + const file = files[index]; + const uploadId = uploadIdsResult.data[index]?.trim(); + + if (!(file instanceof File) || file.size === 0) { + return { + ok: false, + error: zodPhotoError('Each uploaded photo must be a non-empty file', [ + 'newImages', + index, + ]), + }; + } + + if (!file.type?.startsWith('image/')) { + return { + ok: false, + error: zodPhotoError('Uploaded file must be an image', [ + 'newImages', + index, + ]), + }; + } + + if (!uploadId) { + return { + ok: false, + error: zodPhotoError('Missing upload id for photo', [ + 'newImageUploadIds', + index, + ]), + }; + } + + images.push({ uploadId, file }); + } + + if (mode === 'create' && parsedPlan.data.some(item => item.imageId)) { + return { + ok: false, + error: zodPhotoError( + 'Create photo plan cannot reference existing images', + ['photoPlan'] + ), + }; + } + + return { + ok: true, + data: { + imagePlan: parsedPlan.data, + images, + }, + }; +} diff --git a/frontend/lib/services/products/mutations/create.ts b/frontend/lib/services/products/mutations/create.ts index 78512b40..ed7efd77 100644 --- a/frontend/lib/services/products/mutations/create.ts +++ b/frontend/lib/services/products/mutations/create.ts @@ -10,6 +10,7 @@ import type { DbProduct, ProductInput } from '@/lib/types/shop'; import { InvalidPayloadError, SlugConflictError } from '../../errors'; import { mapRowToProduct } from '../mapping'; +import { resolvePhotoPlan } from '../photo-plan'; import { enforceSaleBadgeRequiresOriginal, normalizePricesFromInput, @@ -24,15 +25,6 @@ export async function createProduct(input: ProductInput): Promise { (input as any).slug ?? (input as any).title ); - let uploaded: { secureUrl: string; publicId: string } | null = null; - - try { - uploaded = await uploadProductImageFromFile((input as any).image); - } catch (error) { - logError('Failed to upload product image', error); - throw error; - } - const prices = normalizePricesFromInput(input); if (!prices.length) { throw new InvalidPayloadError('Product pricing is required.'); @@ -45,7 +37,102 @@ export async function createProduct(input: ProductInput): Promise { const usd = requireUsd(prices); + const legacyImage = + (input as any).image instanceof File && (input as any).image.size > 0 + ? ((input as any).image as File) + : null; + + const requestedUploads = + Array.isArray((input as any).images) && (input as any).images.length > 0 + ? ((input as any).images as Array<{ uploadId: string; file: File }>) + : legacyImage + ? [{ uploadId: 'legacy-image', file: legacyImage }] + : []; + + const requestedPhotoPlan = + Array.isArray((input as any).imagePlan) && + (input as any).imagePlan.length > 0 + ? (input as any).imagePlan + : legacyImage + ? [{ uploadId: 'legacy-image', isPrimary: true }] + : []; + + if (!requestedPhotoPlan.length) { + const error = new InvalidPayloadError( + 'At least one product photo is required.', + { + code: 'IMAGE_REQUIRED', + } + ); + (error as any).field = 'photos'; + throw error; + } + + const resolvedPhotoPlan = resolvePhotoPlan({ + mode: 'create', + photoPlan: requestedPhotoPlan, + uploads: requestedUploads, + }); + + const uploadedById = new Map< + string, + { secureUrl: string; publicId: string } + >(); + try { + for (const upload of requestedUploads) { + if (!upload.file.type?.startsWith('image/')) { + const error = new InvalidPayloadError( + 'Uploaded file must be an image.', + { + code: 'INVALID_PRODUCT_PHOTOS', + } + ); + (error as any).field = 'photos'; + throw error; + } + + const uploaded = await uploadProductImageFromFile(upload.file); + uploadedById.set(upload.uploadId, uploaded); + } + } catch (error) { + for (const uploaded of uploadedById.values()) { + try { + await destroyProductImage(uploaded.publicId); + } catch (cleanupError) { + logError( + 'Failed to cleanup uploaded image after create upload failure', + cleanupError + ); + } + } + logError('Failed to upload product image', error); + throw error; + } + + try { + const primaryPhoto = resolvedPhotoPlan.find(item => item.isPrimary); + if (!primaryPhoto || primaryPhoto.source !== 'new') { + const error = new InvalidPayloadError( + 'A primary product photo is required.', + { + code: 'INVALID_PRODUCT_PHOTOS', + } + ); + (error as any).field = 'photos'; + throw error; + } + + const primaryUpload = uploadedById.get(primaryPhoto.uploadId); + if (!primaryUpload) { + const error = new InvalidPayloadError( + 'Primary product photo upload is missing.', + { code: 'INVALID_PRODUCT_PHOTOS' } + ); + (error as any).field = 'photos'; + throw error; + } + const row = await db.transaction(async tx => { const [inserted] = await tx .insert(products) @@ -53,8 +140,8 @@ export async function createProduct(input: ProductInput): Promise { slug, title: (input as any).title, description: (input as any).description ?? null, - imageUrl: uploaded?.secureUrl ?? '', - imagePublicId: uploaded?.publicId, + imageUrl: primaryUpload.secureUrl, + imagePublicId: primaryUpload.publicId, price: toDbMoney(usd.priceMinor), originalPrice: usd.originalPriceMinor == null @@ -96,20 +183,41 @@ export async function createProduct(input: ProductInput): Promise { }) ); - await tx.insert(productImages).values({ - productId: inserted.id, - imageUrl: uploaded?.secureUrl ?? '', - imagePublicId: uploaded?.publicId ?? null, - sortOrder: 0, - isPrimary: true, - }); + await tx.insert(productImages).values( + resolvedPhotoPlan.map(item => { + if (item.source !== 'new') { + throw new InvalidPayloadError( + 'Create product photo plan cannot reference existing images.', + { code: 'INVALID_PRODUCT_PHOTOS' } + ); + } + + const uploaded = uploadedById.get(item.uploadId); + if (!uploaded) { + throw new InvalidPayloadError( + 'Uploaded product photo is missing.', + { + code: 'INVALID_PRODUCT_PHOTOS', + } + ); + } + + return { + productId: inserted.id, + imageUrl: uploaded.secureUrl, + imagePublicId: uploaded.publicId, + sortOrder: item.sortOrder, + isPrimary: item.isPrimary, + }; + }) + ); return inserted; }); return await mapRowToProduct(row); } catch (error) { - if (uploaded?.publicId) { + for (const uploaded of uploadedById.values()) { try { await destroyProductImage(uploaded.publicId); } catch (cleanupError) { diff --git a/frontend/lib/services/products/mutations/update.ts b/frontend/lib/services/products/mutations/update.ts index 400b5e95..b10e29c6 100644 --- a/frontend/lib/services/products/mutations/update.ts +++ b/frontend/lib/services/products/mutations/update.ts @@ -12,9 +12,10 @@ import type { CurrencyCode } from '@/lib/shop/currency'; import { toDbMoney } from '@/lib/shop/money'; import type { DbProduct, ProductUpdateInput } from '@/lib/types/shop'; -import { SlugConflictError } from '../../errors'; +import { InvalidPayloadError, SlugConflictError } from '../../errors'; import { getProductImagesByProductId } from '../images'; import { mapRowToProduct } from '../mapping'; +import { resolvePhotoPlan } from '../photo-plan'; import { assertMergedPricesPolicy, assertMoneyMinorInt, @@ -43,6 +44,20 @@ export async function updateProduct( const currentPrimaryImage = existingImages.find(image => image.isPrimary) ?? null; + const legacyImage = + (input as any).image instanceof File && (input as any).image.size > 0 + ? ((input as any).image as File) + : null; + + const requestedUploads = + Array.isArray((input as any).images) && (input as any).images.length > 0 + ? ((input as any).images as Array<{ uploadId: string; file: File }>) + : []; + + const hasExplicitPhotoPlan = + Array.isArray((input as any).imagePlan) && + (input as any).imagePlan.length > 0; + const slug = await normalizeSlug( db, (input as any).slug ?? (input as any).title ?? existing.slug, @@ -51,9 +66,9 @@ export async function updateProduct( let uploaded: { secureUrl: string; publicId: string } | null = null; - if ((input as any).image instanceof File && (input as any).image.size > 0) { + if (!hasExplicitPhotoPlan && legacyImage) { try { - uploaded = await uploadProductImageFromFile((input as any).image); + uploaded = await uploadProductImageFromFile(legacyImage); } catch (error) { logError('Failed to upload replacement image', error); throw error; @@ -109,9 +124,76 @@ export async function updateProduct( } } - const mirroredImageUrl = currentPrimaryImage?.imageUrl ?? existing.imageUrl; + let resolvedPhotoPlan: ReturnType | undefined; + const uploadedById = new Map< + string, + { secureUrl: string; publicId: string } + >(); + + if (hasExplicitPhotoPlan) { + resolvedPhotoPlan = resolvePhotoPlan({ + mode: 'update', + photoPlan: (input as any).imagePlan, + existingImages, + uploads: requestedUploads, + }); + + try { + for (const requestedUpload of requestedUploads) { + if (!requestedUpload.file.type?.startsWith('image/')) { + const error = new InvalidPayloadError( + 'Uploaded file must be an image.', + { code: 'INVALID_PRODUCT_PHOTOS' } + ); + (error as any).field = 'photos'; + throw error; + } + + const uploadedImage = await uploadProductImageFromFile( + requestedUpload.file + ); + uploadedById.set(requestedUpload.uploadId, uploadedImage); + } + } catch (error) { + for (const uploadedImage of uploadedById.values()) { + try { + await destroyProductImage(uploadedImage.publicId); + } catch (cleanupError) { + logError( + 'Failed to cleanup uploaded image after update upload failure', + cleanupError + ); + } + } + logError('Failed to upload admin product photos', error); + throw error; + } + } + + const explicitPrimary = + resolvedPhotoPlan?.find(item => item.isPrimary) ?? null; + const explicitPrimaryImageUrl = + explicitPrimary?.source === 'existing' + ? explicitPrimary.existingImage.imageUrl + : explicitPrimary?.source === 'new' + ? uploadedById.get(explicitPrimary.uploadId)?.secureUrl + : undefined; + const explicitPrimaryImagePublicId = + explicitPrimary?.source === 'existing' + ? explicitPrimary.existingImage.imagePublicId + : explicitPrimary?.source === 'new' + ? uploadedById.get(explicitPrimary.uploadId)?.publicId + : undefined; + + const mirroredImageUrl = + explicitPrimaryImageUrl ?? + currentPrimaryImage?.imageUrl ?? + existing.imageUrl; const mirroredImagePublicId = - currentPrimaryImage?.imagePublicId ?? existing.imagePublicId ?? undefined; + explicitPrimaryImagePublicId ?? + currentPrimaryImage?.imagePublicId ?? + existing.imagePublicId ?? + undefined; const updateData: Partial = { slug, @@ -173,7 +255,65 @@ export async function updateProduct( }); } - if (uploaded) { + if (resolvedPhotoPlan) { + const retainedExistingIds = new Set( + resolvedPhotoPlan + .filter( + ( + item + ): item is Extract< + (typeof resolvedPhotoPlan)[number], + { source: 'existing' } + > => item.source === 'existing' + ) + .map(item => item.imageId) + ); + + const removedImages = existingImages.filter( + image => !retainedExistingIds.has(image.id) + ); + + if (removedImages.length) { + await tx.delete(productImages).where( + sql`${productImages.productId} = ${id} and ${productImages.id} in (${sql.join( + removedImages.map(image => sql`${image.id}`), + sql`, ` + )})` + ); + } + + for (const item of resolvedPhotoPlan) { + if (item.source === 'existing') { + await tx + .update(productImages) + .set({ + sortOrder: item.sortOrder, + isPrimary: item.isPrimary, + updatedAt: new Date(), + }) + .where(eq(productImages.id, item.imageId)); + continue; + } + + const uploadedImage = uploadedById.get(item.uploadId); + if (!uploadedImage) { + const error = new InvalidPayloadError( + 'Uploaded product photo is missing.', + { code: 'INVALID_PRODUCT_PHOTOS' } + ); + (error as any).field = 'photos'; + throw error; + } + + await tx.insert(productImages).values({ + productId: id, + imageUrl: uploadedImage.secureUrl, + imagePublicId: uploadedImage.publicId, + sortOrder: item.sortOrder, + isPrimary: item.isPrimary, + }); + } + } else if (uploaded) { if (currentPrimaryImage) { await tx .update(productImages) @@ -213,6 +353,34 @@ export async function updateProduct( return updatedRow; }); + if (resolvedPhotoPlan) { + const retainedExistingIds = new Set( + resolvedPhotoPlan + .filter( + ( + item + ): item is Extract< + (typeof resolvedPhotoPlan)[number], + { source: 'existing' } + > => item.source === 'existing' + ) + .map(item => item.imageId) + ); + + const removedImages = existingImages.filter( + image => !retainedExistingIds.has(image.id) + ); + + for (const removedImage of removedImages) { + if (!removedImage.imagePublicId) continue; + try { + await destroyProductImage(removedImage.imagePublicId); + } catch (cleanupError) { + logError('Failed to cleanup removed product image', cleanupError); + } + } + } + const replacedPrimaryPublicId = currentPrimaryImage?.imagePublicId ?? existing.imagePublicId ?? null; @@ -230,6 +398,17 @@ export async function updateProduct( return await mapRowToProduct(row); } catch (error) { + for (const uploadedImage of uploadedById.values()) { + try { + await destroyProductImage(uploadedImage.publicId); + } catch (cleanupError) { + logError( + 'Failed to cleanup uploaded image after update failure', + cleanupError + ); + } + } + if (uploaded?.publicId) { try { await destroyProductImage(uploaded.publicId); diff --git a/frontend/lib/services/products/photo-plan.ts b/frontend/lib/services/products/photo-plan.ts new file mode 100644 index 00000000..8c4d1934 --- /dev/null +++ b/frontend/lib/services/products/photo-plan.ts @@ -0,0 +1,130 @@ +import type { ProductImage, ProductImageUploadInput } from '@/lib/types/shop'; +import type { AdminProductPhotoPlan } from '@/lib/validation/shop'; + +import { InvalidPayloadError } from '../errors'; + +type ResolvePhotoPlanOptions = { + mode: 'create' | 'update'; + photoPlan: AdminProductPhotoPlan; + existingImages?: ProductImage[]; + uploads?: ProductImageUploadInput[]; +}; + +export type ResolvedPhotoPlanItem = + | { + source: 'existing'; + imageId: string; + existingImage: ProductImage; + isPrimary: boolean; + sortOrder: number; + } + | { + source: 'new'; + uploadId: string; + upload: ProductImageUploadInput; + isPrimary: boolean; + sortOrder: number; + }; + +function photoPayloadError( + message: string, + details?: Record +): InvalidPayloadError { + const error = new InvalidPayloadError(message, { + code: 'INVALID_PRODUCT_PHOTOS', + details, + }); + (error as any).field = 'photos'; + return error; +} + +export function resolvePhotoPlan({ + mode, + photoPlan, + existingImages = [], + uploads = [], +}: ResolvePhotoPlanOptions): ResolvedPhotoPlanItem[] { + if (!photoPlan.length) { + throw photoPayloadError('At least one product photo is required.'); + } + + const existingById = new Map(existingImages.map(image => [image.id, image])); + const uploadsById = new Map(uploads.map(upload => [upload.uploadId, upload])); + + const resolved = photoPlan.map((item, index) => { + if (item.imageId) { + const existingImage = existingById.get(item.imageId); + if (!existingImage) { + throw photoPayloadError( + 'Photo plan references an unknown existing image.', + { + imageId: item.imageId, + mode, + } + ); + } + + return { + source: 'existing' as const, + imageId: item.imageId, + existingImage, + isPrimary: item.isPrimary, + sortOrder: index, + }; + } + + if (!item.uploadId) { + throw photoPayloadError( + 'Photo plan item is missing an upload reference.' + ); + } + + const upload = uploadsById.get(item.uploadId); + if (!upload) { + throw photoPayloadError( + 'Photo plan references an unknown uploaded photo.', + { + uploadId: item.uploadId, + mode, + } + ); + } + + return { + source: 'new' as const, + uploadId: item.uploadId, + upload, + isPrimary: item.isPrimary, + sortOrder: index, + }; + }); + + const usedUploadIds = new Set( + resolved + .filter( + (item): item is Extract => + item.source === 'new' + ) + .map(item => item.uploadId) + ); + + for (const upload of uploads) { + if (!usedUploadIds.has(upload.uploadId)) { + throw photoPayloadError( + 'Uploaded photo is not referenced in the photo plan.', + { + uploadId: upload.uploadId, + mode, + } + ); + } + } + + if (mode === 'create' && resolved.some(item => item.source === 'existing')) { + throw photoPayloadError( + 'Create photo plan cannot reference existing product images.' + ); + } + + return resolved; +} diff --git a/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts b/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts index 98fa9abb..ae22e32a 100644 --- a/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts +++ b/frontend/lib/tests/shop/admin-product-canonical-audit-phase5.test.ts @@ -1,6 +1,8 @@ import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { WriteAdminAuditArgs } from '@/lib/services/shop/events/write-admin-audit'; + const adminUser = { id: 'admin_user_1', role: 'admin', @@ -11,16 +13,27 @@ const mocks = vi.hoisted(() => ({ requireAdminApi: vi.fn(async () => adminUser), requireAdminCsrf: vi.fn(() => null), parseAdminProductForm: vi.fn(), + parseAdminProductPhotosForm: vi.fn((formData: FormData) => ({ + ok: true, + data: { + imagePlan: [{ uploadId: 'legacy-image', isPrimary: true }], + images: [ + { uploadId: 'legacy-image', file: formData.get('image') as File }, + ], + }, + })), createProduct: vi.fn(), updateProduct: vi.fn(), deleteProduct: vi.fn(), toggleProductStatus: vi.fn(), getAdminProductByIdWithPrices: vi.fn(), - writeAdminAudit: vi.fn(async () => ({ - inserted: true, - dedupeKey: 'admin_audit:v1:test', - id: 'audit_row_1', - })), + writeAdminAudit: vi.fn( + async (_args: WriteAdminAuditArgs, _options?: { db?: unknown }) => ({ + inserted: true, + dedupeKey: 'admin_audit:v1:test', + id: 'audit_row_1', + }) + ), })); vi.mock('@/lib/auth/admin', () => { @@ -47,6 +60,7 @@ vi.mock('@/lib/security/admin-csrf', () => ({ vi.mock('@/lib/admin/parseAdminProductForm', () => ({ parseAdminProductForm: mocks.parseAdminProductForm, + parseAdminProductPhotosForm: mocks.parseAdminProductPhotosForm, })); vi.mock('@/lib/services/products', () => ({ diff --git a/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts b/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts index 6b96daa0..8f8c8cf4 100644 --- a/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts +++ b/frontend/lib/tests/shop/admin-product-create-atomic-phasec.test.ts @@ -17,6 +17,15 @@ const mocks = vi.hoisted(() => ({ requireAdminApi: vi.fn(async () => adminUser), requireAdminCsrf: vi.fn(() => null), parseAdminProductForm: vi.fn(), + parseAdminProductPhotosForm: vi.fn((formData: FormData) => ({ + ok: true, + data: { + imagePlan: [{ uploadId: 'legacy-image', isPrimary: true }], + images: [ + { uploadId: 'legacy-image', file: formData.get('image') as File }, + ], + }, + })), writeAdminAudit: vi.fn(async () => { throw new Error('audit-fail'); }), @@ -47,6 +56,7 @@ vi.mock('@/lib/security/admin-csrf', () => ({ vi.mock('@/lib/admin/parseAdminProductForm', () => ({ parseAdminProductForm: mocks.parseAdminProductForm, + parseAdminProductPhotosForm: mocks.parseAdminProductPhotosForm, })); vi.mock('@/lib/services/shop/events/write-admin-audit', () => ({ diff --git a/frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts b/frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts index 785ff55e..b9a9509e 100644 --- a/frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts +++ b/frontend/lib/tests/shop/admin-product-patch-price-config-error-contract.test.ts @@ -21,6 +21,10 @@ vi.mock('@/lib/admin/parseAdminProductForm', () => ({ ok: true, data: { badge: 'NONE', prices: [{ currency: 'UAH', priceMinor: 1000 }] }, })), + parseAdminProductPhotosForm: vi.fn(() => ({ + ok: true, + data: { imagePlan: undefined, images: [] }, + })), })); vi.mock('@/lib/services/products', () => ({ diff --git a/frontend/lib/tests/shop/admin-product-photo-management.test.ts b/frontend/lib/tests/shop/admin-product-photo-management.test.ts new file mode 100644 index 00000000..528de612 --- /dev/null +++ b/frontend/lib/tests/shop/admin-product-photo-management.test.ts @@ -0,0 +1,293 @@ +import { randomUUID } from 'node:crypto'; + +import { asc, eq } from 'drizzle-orm'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const cloudinaryMocks = vi.hoisted(() => ({ + uploadProductImageFromFile: vi.fn(), + destroyProductImage: vi.fn(), +})); + +vi.mock('@/lib/cloudinary', () => cloudinaryMocks); + +import { db } from '@/db'; +import { productImages, productPrices, products } from '@/db/schema'; +import { InvalidPayloadError } from '@/lib/services/errors'; +import { createProduct, updateProduct } from '@/lib/services/products'; +import { toDbMoney } from '@/lib/shop/money'; + +async function cleanupProduct(productId: string) { + await db.delete(products).where(eq(products.id, productId)); +} + +describe.sequential('admin product photo management', () => { + const createdProductIds: string[] = []; + + afterEach(async () => { + vi.clearAllMocks(); + + for (const productId of createdProductIds.splice(0)) { + await cleanupProduct(productId); + } + }); + + it('createProduct supports multiple uploaded photos with explicit primary and stable ordering', async () => { + cloudinaryMocks.uploadProductImageFromFile + .mockResolvedValueOnce({ + secureUrl: 'https://example.com/u1.png', + publicId: 'products/u1', + }) + .mockResolvedValueOnce({ + secureUrl: 'https://example.com/u2.png', + publicId: 'products/u2', + }) + .mockResolvedValueOnce({ + secureUrl: 'https://example.com/u3.png', + publicId: 'products/u3', + }); + + const created = await createProduct({ + title: `Photo create ${randomUUID()}`, + badge: 'NONE', + colors: [], + sizes: [], + stock: 5, + isActive: true, + isFeatured: false, + prices: [{ currency: 'USD', priceMinor: 3200, originalPriceMinor: null }], + images: [ + { + uploadId: 'u1', + file: new File([new Uint8Array([1])], 'u1.png', { + type: 'image/png', + }), + }, + { + uploadId: 'u2', + file: new File([new Uint8Array([2])], 'u2.png', { + type: 'image/png', + }), + }, + { + uploadId: 'u3', + file: new File([new Uint8Array([3])], 'u3.png', { + type: 'image/png', + }), + }, + ], + imagePlan: [ + { uploadId: 'u2', isPrimary: false }, + { uploadId: 'u1', isPrimary: true }, + { uploadId: 'u3', isPrimary: false }, + ], + }); + + createdProductIds.push(created.id); + + const imageRows = await db + .select({ + imageUrl: productImages.imageUrl, + imagePublicId: productImages.imagePublicId, + sortOrder: productImages.sortOrder, + isPrimary: productImages.isPrimary, + }) + .from(productImages) + .where(eq(productImages.productId, created.id)) + .orderBy(asc(productImages.sortOrder)); + + expect(created.imageUrl).toBe('https://example.com/u1.png'); + expect(created.primaryImage?.imageUrl).toBe('https://example.com/u1.png'); + expect(created.images.map(image => image.imageUrl)).toEqual([ + 'https://example.com/u2.png', + 'https://example.com/u1.png', + 'https://example.com/u3.png', + ]); + expect(imageRows).toEqual([ + { + imageUrl: 'https://example.com/u2.png', + imagePublicId: 'products/u2', + sortOrder: 0, + isPrimary: false, + }, + { + imageUrl: 'https://example.com/u1.png', + imagePublicId: 'products/u1', + sortOrder: 1, + isPrimary: true, + }, + { + imageUrl: 'https://example.com/u3.png', + imagePublicId: 'products/u3', + sortOrder: 2, + isPrimary: false, + }, + ]); + }); + + it('updateProduct can remove, reorder, add, and reassign primary photos safely', async () => { + const productId = randomUUID(); + createdProductIds.push(productId); + + await db.insert(products).values({ + id: productId, + slug: `photo-update-${randomUUID()}`, + title: 'Photo update product', + description: null, + imageUrl: 'https://example.com/p1.png', + imagePublicId: 'products/p1', + price: toDbMoney(4500), + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 6, + sku: null, + }); + + await db.insert(productPrices).values({ + productId, + currency: 'USD', + priceMinor: 4500, + originalPriceMinor: null, + price: toDbMoney(4500), + originalPrice: null, + }); + + const [primaryImage, secondaryImage] = await db + .insert(productImages) + .values([ + { + productId, + imageUrl: 'https://example.com/p1.png', + imagePublicId: 'products/p1', + sortOrder: 0, + isPrimary: true, + }, + { + productId, + imageUrl: 'https://example.com/p2.png', + imagePublicId: 'products/p2', + sortOrder: 1, + isPrimary: false, + }, + ]) + .returning(); + + cloudinaryMocks.uploadProductImageFromFile.mockResolvedValueOnce({ + secureUrl: 'https://example.com/p3.png', + publicId: 'products/p3', + }); + + const updated = await updateProduct(productId, { + title: 'Updated photo product', + imagePlan: [ + { imageId: secondaryImage.id, isPrimary: true }, + { uploadId: 'p3-upload', isPrimary: false }, + ], + images: [ + { + uploadId: 'p3-upload', + file: new File([new Uint8Array([4])], 'p3.png', { + type: 'image/png', + }), + }, + ], + }); + + const imageRows = await db + .select({ + id: productImages.id, + imageUrl: productImages.imageUrl, + imagePublicId: productImages.imagePublicId, + sortOrder: productImages.sortOrder, + isPrimary: productImages.isPrimary, + }) + .from(productImages) + .where(eq(productImages.productId, productId)) + .orderBy(asc(productImages.sortOrder)); + + const [productRow] = await db + .select({ + imageUrl: products.imageUrl, + imagePublicId: products.imagePublicId, + }) + .from(products) + .where(eq(products.id, productId)) + .limit(1); + + expect(updated.primaryImage?.imageUrl).toBe('https://example.com/p2.png'); + expect(updated.images.map(image => image.imageUrl)).toEqual([ + 'https://example.com/p2.png', + 'https://example.com/p3.png', + ]); + expect(imageRows).toEqual([ + { + id: secondaryImage.id, + imageUrl: 'https://example.com/p2.png', + imagePublicId: 'products/p2', + sortOrder: 0, + isPrimary: true, + }, + { + id: imageRows[1]!.id, + imageUrl: 'https://example.com/p3.png', + imagePublicId: 'products/p3', + sortOrder: 1, + isPrimary: false, + }, + ]); + expect(productRow).toEqual({ + imageUrl: 'https://example.com/p2.png', + imagePublicId: 'products/p2', + }); + expect(cloudinaryMocks.destroyProductImage).toHaveBeenCalledWith( + primaryImage.imagePublicId + ); + }); + + it('updateProduct rejects photo plans that reference unknown existing images', async () => { + const productId = randomUUID(); + createdProductIds.push(productId); + + await db.insert(products).values({ + id: productId, + slug: `photo-invalid-${randomUUID()}`, + title: 'Photo invalid product', + description: null, + imageUrl: 'https://example.com/p1.png', + imagePublicId: 'products/p1', + price: toDbMoney(2700), + originalPrice: null, + currency: 'USD', + category: null, + type: null, + colors: [], + sizes: [], + badge: 'NONE', + isActive: true, + isFeatured: false, + stock: 2, + sku: null, + }); + + await db.insert(productPrices).values({ + productId, + currency: 'USD', + priceMinor: 2700, + originalPriceMinor: null, + price: toDbMoney(2700), + originalPrice: null, + }); + + await expect( + updateProduct(productId, { + imagePlan: [{ imageId: randomUUID(), isPrimary: true }], + }) + ).rejects.toBeInstanceOf(InvalidPayloadError); + }); +}); diff --git a/frontend/lib/tests/shop/admin-product-sale-contract.test.ts b/frontend/lib/tests/shop/admin-product-sale-contract.test.ts index 4373603c..82941c62 100644 --- a/frontend/lib/tests/shop/admin-product-sale-contract.test.ts +++ b/frontend/lib/tests/shop/admin-product-sale-contract.test.ts @@ -1,13 +1,26 @@ import { NextRequest } from 'next/server'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { getCurrentUserMock, parseAdminProductFormMock } = vi.hoisted(() => ({ +const { + getCurrentUserMock, + parseAdminProductFormMock, + parseAdminProductPhotosFormMock, +} = vi.hoisted(() => ({ getCurrentUserMock: vi.fn(async () => ({ id: 'u_test_admin', email: 'admin@test.local', role: 'admin', })), parseAdminProductFormMock: vi.fn(), + parseAdminProductPhotosFormMock: vi.fn((formData: FormData) => ({ + ok: true, + data: { + imagePlan: [{ uploadId: 'legacy-image', isPrimary: true }], + images: [ + { uploadId: 'legacy-image', file: formData.get('image') as File }, + ], + }, + })), })); const { productsServiceMock } = vi.hoisted(() => ({ @@ -27,6 +40,7 @@ vi.mock('@/lib/auth', () => ({ vi.mock('@/lib/admin/parseAdminProductForm', () => ({ parseAdminProductForm: parseAdminProductFormMock, + parseAdminProductPhotosForm: parseAdminProductPhotosFormMock, })); vi.mock('@/lib/security/admin-csrf', () => ({ @@ -60,6 +74,18 @@ describe('P1-3 SALE rule end-to-end contract: admin products API returns stable vi.stubEnv('ENABLE_ADMIN_API', 'true'); parseAdminProductFormMock.mockReset(); + parseAdminProductPhotosFormMock.mockReset(); + parseAdminProductPhotosFormMock.mockImplementation( + (formData: FormData) => ({ + ok: true, + data: { + imagePlan: [{ uploadId: 'legacy-image', isPrimary: true }], + images: [ + { uploadId: 'legacy-image', file: formData.get('image') as File }, + ], + }, + }) + ); productsServiceMock.createProduct.mockReset(); productsServiceMock.updateProduct.mockReset(); productsServiceMock.deleteProduct.mockReset(); diff --git a/frontend/lib/types/shop.ts b/frontend/lib/types/shop.ts index 441e7b39..845f07e3 100644 --- a/frontend/lib/types/shop.ts +++ b/frontend/lib/types/shop.ts @@ -1,6 +1,7 @@ import { z } from 'zod'; import { + adminProductPhotoPlanSchema, checkoutItemSchema, checkoutLegalConsentSchema, checkoutShippingSchema, @@ -15,10 +16,22 @@ import { export type AdminProductPayload = z.infer; -export type ProductInput = AdminProductPayload & { image: File }; +export type AdminProductPhotoPlan = z.infer; +export type ProductImageUploadInput = { + uploadId: string; + file: File; +}; + +export type ProductInput = AdminProductPayload & { + image?: File | null; + images?: ProductImageUploadInput[]; + imagePlan?: AdminProductPhotoPlan; +}; export type ProductUpdateInput = z.infer & { image?: File | null; + images?: ProductImageUploadInput[]; + imagePlan?: AdminProductPhotoPlan; prices?: ProductPriceInput[]; }; diff --git a/frontend/lib/validation/shop.ts b/frontend/lib/validation/shop.ts index 3bf2e8e5..40b66586 100644 --- a/frontend/lib/validation/shop.ts +++ b/frontend/lib/validation/shop.ts @@ -118,6 +118,80 @@ export const productImageSchema = z.object({ updatedAt: z.coerce.date(), }); +export const MAX_ADMIN_PRODUCT_IMAGES = 12; + +const adminProductPhotoUploadIdSchema = z + .string() + .trim() + .min(1) + .max(64) + .regex(/^[A-Za-z0-9_-]+$/); + +export const adminProductPhotoPlanItemSchema = z + .object({ + imageId: z.string().uuid().optional(), + uploadId: adminProductPhotoUploadIdSchema.optional(), + isPrimary: z.boolean(), + }) + .superRefine((value, ctx) => { + const references = + Number(Boolean(value.imageId)) + Number(Boolean(value.uploadId)); + if (references !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['imageId'], + message: + 'Each photo must reference exactly one existing image or one new upload', + }); + } + }); + +export const adminProductPhotoPlanSchema = z + .array(adminProductPhotoPlanItemSchema) + .min(1) + .max(MAX_ADMIN_PRODUCT_IMAGES) + .superRefine((items, ctx) => { + const seenExisting = new Set(); + const seenUploads = new Set(); + let primaryCount = 0; + + items.forEach((item, index) => { + if (item.isPrimary) primaryCount += 1; + + if (item.imageId) { + if (seenExisting.has(item.imageId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [index, 'imageId'], + message: 'Duplicate existing image in photo plan', + }); + } else { + seenExisting.add(item.imageId); + } + } + + if (item.uploadId) { + if (seenUploads.has(item.uploadId)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [index, 'uploadId'], + message: 'Duplicate new upload in photo plan', + }); + } else { + seenUploads.add(item.uploadId); + } + } + }); + + if (primaryCount !== 1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: [], + message: 'Photo plan must contain exactly one primary image', + }); + } + }); + export const dbProductSchema = z.object({ id: z.string(), slug: z.string(), @@ -654,6 +728,10 @@ export const orderPaymentInitPayloadSchema = z export type CatalogQuery = z.infer; export type CatalogFilters = z.infer; +export type AdminProductPhotoPlanItem = z.infer< + typeof adminProductPhotoPlanItemSchema +>; +export type AdminProductPhotoPlan = z.infer; export type ProductImage = z.infer; export type DbProduct = z.infer; export type ShopProductImage = z.infer; From c27f9413b6df90f7acd79e80751b979ae97b9cac Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 25 Mar 2026 12:11:19 -0700 Subject: [PATCH 03/12] (SP: 1)[SHOP] render PDP gallery from multi-image product contract --- .../[locale]/shop/products/[slug]/page.tsx | 128 +++++++++----- frontend/lib/shop/data.ts | 165 ++++++++++++++++-- .../shop/product-gallery-view-model.test.ts | 155 ++++++++++++++++ 3 files changed, 393 insertions(+), 55 deletions(-) create mode 100644 frontend/lib/tests/shop/product-gallery-view-model.test.ts diff --git a/frontend/app/[locale]/shop/products/[slug]/page.tsx b/frontend/app/[locale]/shop/products/[slug]/page.tsx index adceeac2..210084ea 100644 --- a/frontend/app/[locale]/shop/products/[slug]/page.tsx +++ b/frontend/app/[locale]/shop/products/[slug]/page.tsx @@ -5,10 +5,9 @@ import { notFound } from 'next/navigation'; import { getMessages, getTranslations } from 'next-intl/server'; import { AddToCartButton } from '@/components/shop/AddToCartButton'; -import { getPublicProductBySlug } from '@/db/queries/shop/products'; import { Link } from '@/i18n/routing'; -import { formatMoney, resolveCurrencyFromLocale } from '@/lib/shop/currency'; -import { getProductPageData } from '@/lib/shop/data'; +import { formatMoney } from '@/lib/shop/currency'; +import { getProductGalleryImages, getProductPageData } from '@/lib/shop/data'; import { SHOP_FOCUS, SHOP_NAV_LINK_BASE } from '@/lib/shop/ui-classes'; import { cn } from '@/lib/utils'; @@ -18,6 +17,28 @@ export const metadata: Metadata = { }; export const dynamic = 'force-dynamic'; +const PLACEHOLDER_IMAGE = '/placeholder.svg'; +const allowedHosts = new Set(['res.cloudinary.com', 'cdn.sanity.io']); + +function safeImageSrc(raw?: string | null) { + if (!raw || raw.trim().length === 0) return PLACEHOLDER_IMAGE; + + const src = raw.trim(); + + if (src.startsWith('/')) return src; + + if (src.startsWith('http://') || src.startsWith('https://')) { + try { + const url = new URL(src); + return allowedHosts.has(url.hostname) ? src : PLACEHOLDER_IMAGE; + } catch { + return PLACEHOLDER_IMAGE; + } + } + + return PLACEHOLDER_IMAGE; +} + export default async function ProductPage({ params, }: { @@ -27,26 +48,18 @@ export default async function ProductPage({ const t = await getTranslations('shop.products'); const tProduct = await getTranslations('shop.product'); - const currency = resolveCurrencyFromLocale(locale); - const publicProduct = await getPublicProductBySlug(slug, currency); - if (!publicProduct) { - notFound(); - } - const result = await getProductPageData(slug, locale); if (result.kind === 'not_found') { notFound(); } - const isUnavailable = result.kind === 'unavailable'; - const resultProduct = (result as any).product ?? {}; - const product = { - ...(publicProduct as any), - ...Object.fromEntries( - Object.entries(resultProduct).filter(([, v]) => v !== undefined) - ), - } as any; + const product = result.product; + const commerceProduct = + result.kind === 'available' ? result.commerceProduct : null; + const galleryImages = getProductGalleryImages(product); + const primaryImage = galleryImages[0]; + const secondaryImages = galleryImages.slice(1); const NAV_LINK = cn( SHOP_NAV_LINK_BASE, @@ -77,26 +90,47 @@ export default async function ProductPage({

-
- {badge && badge !== 'NONE' && ( - - {badgeLabel} - - )} +
+
+ {badge && badge !== 'NONE' && ( + + {badgeLabel} + + )} + + {`${product.name} +
- {product.name} + {secondaryImages.length > 0 ? ( +
+ {secondaryImages.map((image, index) => ( +
+ {`${product.name} +
+ ))} +
+ ) : null}
@@ -104,7 +138,7 @@ export default async function ProductPage({ {product.name} - {isUnavailable ? ( + {commerceProduct === null ? (
- {formatMoney(product.price, product.currency, locale)} + {formatMoney( + commerceProduct.price, + commerceProduct.currency, + locale + )} - {product.originalPrice && ( + {commerceProduct.originalPrice && ( - {formatMoney(product.originalPrice, product.currency, locale)} + {formatMoney( + commerceProduct.originalPrice, + commerceProduct.currency, + locale + )} )}
@@ -146,11 +188,11 @@ export default async function ProductPage({ ); })()} - {!isUnavailable && ( + {commerceProduct ? (
- +
- )} + ) : null} diff --git a/frontend/lib/shop/data.ts b/frontend/lib/shop/data.ts index cc3c1b63..e4401234 100644 --- a/frontend/lib/shop/data.ts +++ b/frontend/lib/shop/data.ts @@ -19,6 +19,7 @@ import { productBadgeValues, type ProductImage, type ShopProduct as ValidationShopProduct, + type ShopProductImage, shopProductSchema, } from '@/lib/validation/shop'; @@ -47,18 +48,27 @@ export interface CatalogPage { hasMore: boolean; } +export interface ProductPageDisplayProduct { + id: string; + slug: string; + name: string; + image: string; + images: ShopProductImage[]; + primaryImage?: ShopProductImage; + description?: string; + badge: ProductBadge; +} + export type ProductPageData = - | { kind: 'available'; product: ShopProduct } + | { + kind: 'available'; + product: ProductPageDisplayProduct; + commerceProduct: ShopProduct; + } | { kind: 'unavailable'; - product: { - id: string; - slug: string; - name: string; - image: string; - description?: string; - badge: ProductBadge; - }; + product: ProductPageDisplayProduct; + commerceProduct: null; } | { kind: 'not_found' }; @@ -71,7 +81,22 @@ export async function getProductPageData( const dbProduct = await getPublicProductBySlug(slug, currency); if (dbProduct) { const mapped = mapToShopProduct(dbProduct); - if (mapped) return { kind: 'available', product: mapped }; + if (mapped) { + return toProductPageViewModel({ + kind: 'available', + product: toProductPageDisplayProduct({ + id: mapped.id, + slug: mapped.slug, + name: mapped.name, + image: mapped.image, + images: mapped.images, + primaryImage: mapped.primaryImage, + description: mapped.description, + badge: mapped.badge ?? 'NONE', + }), + commerceProduct: mapped, + }); + } return { kind: 'not_found' }; } @@ -84,17 +109,20 @@ export async function getProductPageData( ? (base.badge as ProductBadge) : 'NONE'; - return { + return toProductPageViewModel({ kind: 'unavailable', product: { id: base.id, slug: base.slug, name: base.title, image: base.imageUrl || placeholderImage, + images: [], + primaryImage: undefined, description: base.description ?? undefined, badge, }, - }; + commerceProduct: null, + }); } export class CatalogValidationError extends Error { @@ -110,6 +138,18 @@ export class CatalogValidationError extends Error { const placeholderImage = '/placeholder.svg'; +type ProductGallerySource = { + image?: string; + images?: ShopProductImage[]; + primaryImage?: ShopProductImage; +}; + +function getGalleryIdentity( + image: Pick +): string { + return image.id || image.url; +} + function mapToShopProductImage(image: ProductImage) { return { id: image.id, @@ -120,6 +160,107 @@ function mapToShopProductImage(image: ProductImage) { }; } +function toProductPageDisplayProduct(input: { + id: string; + slug: string; + name: string; + image: string; + images?: ShopProductImage[]; + primaryImage?: ShopProductImage; + description?: string; + badge: ProductBadge; +}): ProductPageDisplayProduct { + return { + id: input.id, + slug: input.slug, + name: input.name, + image: input.image, + images: input.images ?? [], + primaryImage: input.primaryImage, + description: input.description, + badge: input.badge, + }; +} + +export function toProductPageViewModel(data: ProductPageData): ProductPageData { + if (data.kind === 'not_found') return data; + + if (data.kind === 'available') { + return { + kind: 'available', + product: toProductPageDisplayProduct({ + id: data.commerceProduct.id, + slug: data.commerceProduct.slug, + name: data.commerceProduct.name, + image: data.commerceProduct.image, + images: data.commerceProduct.images, + primaryImage: data.commerceProduct.primaryImage, + description: data.commerceProduct.description, + badge: data.commerceProduct.badge ?? 'NONE', + }), + commerceProduct: data.commerceProduct, + }; + } + + return { + kind: 'unavailable', + product: toProductPageDisplayProduct(data.product), + commerceProduct: null, + }; +} + +export function getProductGalleryImages( + product: ProductGallerySource +): ShopProductImage[] { + const explicitImages = Array.isArray(product.images) + ? product.images.filter( + image => typeof image?.url === 'string' && image.url.trim().length > 0 + ) + : []; + + const explicitPrimary = + (product.primaryImage && + typeof product.primaryImage.url === 'string' && + product.primaryImage.url.trim().length > 0 + ? product.primaryImage + : undefined) ?? + explicitImages.find(image => image.isPrimary) ?? + explicitImages[0]; + + if (explicitImages.length > 0) { + const seen = new Set(); + const ordered: ShopProductImage[] = []; + + const pushImage = (image?: ShopProductImage) => { + if (!image) return; + const identity = getGalleryIdentity(image); + if (seen.has(identity)) return; + seen.add(identity); + ordered.push(image); + }; + + pushImage(explicitPrimary); + explicitImages.forEach(pushImage); + + return ordered; + } + + const fallbackUrl = + typeof product.image === 'string' && product.image.trim().length > 0 + ? product.image + : placeholderImage; + + return [ + { + id: 'fallback:primary', + url: fallbackUrl, + publicId: undefined, + sortOrder: 0, + isPrimary: true, + }, + ]; +} + function deriveStock(product: DbProduct): boolean { if (!product.isActive) return false; return product.stock > 0; diff --git a/frontend/lib/tests/shop/product-gallery-view-model.test.ts b/frontend/lib/tests/shop/product-gallery-view-model.test.ts new file mode 100644 index 00000000..b945998c --- /dev/null +++ b/frontend/lib/tests/shop/product-gallery-view-model.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; + +import { + getProductGalleryImages, + toProductPageViewModel, +} from '@/lib/shop/data'; + +describe('product gallery view model', () => { + it('renders the explicit primary image first while preserving the remaining image order', () => { + const gallery = getProductGalleryImages({ + image: 'https://example.com/legacy.png', + primaryImage: { + id: 'img-primary', + url: 'https://example.com/primary.png', + publicId: 'products/primary', + sortOrder: 20, + isPrimary: true, + }, + images: [ + { + id: 'img-secondary', + url: 'https://example.com/secondary.png', + publicId: 'products/secondary', + sortOrder: 10, + isPrimary: false, + }, + { + id: 'img-primary', + url: 'https://example.com/primary.png', + publicId: 'products/primary', + sortOrder: 20, + isPrimary: true, + }, + { + id: 'img-third', + url: 'https://example.com/third.png', + publicId: 'products/third', + sortOrder: 30, + isPrimary: false, + }, + ], + }); + + expect(gallery.map(image => image.id)).toEqual([ + 'img-primary', + 'img-secondary', + 'img-third', + ]); + }); + + it('falls back to the legacy single-image field when no explicit gallery exists', () => { + const gallery = getProductGalleryImages({ + image: 'https://example.com/legacy.png', + images: [], + primaryImage: undefined, + }); + + expect(gallery).toEqual([ + { + id: 'fallback:primary', + url: 'https://example.com/legacy.png', + publicId: undefined, + sortOrder: 0, + isPrimary: true, + }, + ]); + }); + + it('falls back to the placeholder image when gallery and legacy image data are missing', () => { + const gallery = getProductGalleryImages({ + image: '', + images: [], + primaryImage: undefined, + }); + + expect(gallery).toEqual([ + { + id: 'fallback:primary', + url: '/placeholder.svg', + publicId: undefined, + sortOrder: 0, + isPrimary: true, + }, + ]); + }); + + it('normalizes PDP display and commerce data into separate concrete branches', () => { + const viewModel = toProductPageViewModel({ + kind: 'available', + product: { + id: 'product-1', + slug: 'product-1', + name: 'Product 1', + image: 'https://example.com/primary.png', + images: [ + { + id: 'img-primary', + url: 'https://example.com/primary.png', + publicId: 'products/primary', + sortOrder: 0, + isPrimary: true, + }, + ], + primaryImage: { + id: 'img-primary', + url: 'https://example.com/primary.png', + publicId: 'products/primary', + sortOrder: 0, + isPrimary: true, + }, + description: 'desc', + badge: 'SALE', + }, + commerceProduct: { + id: 'product-1', + slug: 'product-1', + name: 'Product 1', + price: 5000, + currency: 'USD', + image: 'https://example.com/primary.png', + images: [ + { + id: 'img-primary', + url: 'https://example.com/primary.png', + publicId: 'products/primary', + sortOrder: 0, + isPrimary: true, + }, + ], + primaryImage: { + id: 'img-primary', + url: 'https://example.com/primary.png', + publicId: 'products/primary', + sortOrder: 0, + isPrimary: true, + }, + originalPrice: 6500, + colors: ['black'], + sizes: ['L'], + description: 'desc', + badge: 'SALE', + inStock: true, + }, + }); + + expect(viewModel.kind).toBe('available'); + if (viewModel.kind !== 'available') { + throw new Error('Expected available product page data'); + } + + expect(viewModel.product.name).toBe('Product 1'); + expect(viewModel.commerceProduct.price).toBe(5000); + expect(viewModel.commerceProduct.currency).toBe('USD'); + }); +}); From 858b18045d8f36527593c3ef8403275d385bb8e6 Mon Sep 17 00:00:00 2001 From: liudmylasovetovs Date: Wed, 25 Mar 2026 12:46:20 -0700 Subject: [PATCH 04/12] (SP: 1)[SHOP] align storefront availability styling with shop design system --- .../[locale]/shop/products/[slug]/page.tsx | 27 ++++++-- frontend/components/shop/AddToCartButton.tsx | 4 +- frontend/components/shop/CartProvider.tsx | 2 +- frontend/components/shop/ProductCard.tsx | 63 +++++++++++-------- frontend/lib/shop/availability.ts | 18 ++++++ frontend/lib/shop/data.ts | 2 + .../product-availability-view-model.test.ts | 47 ++++++++++++++ frontend/messages/en.json | 6 ++ frontend/messages/pl.json | 6 ++ frontend/messages/uk.json | 6 ++ 10 files changed, 146 insertions(+), 35 deletions(-) create mode 100644 frontend/lib/shop/availability.ts create mode 100644 frontend/lib/tests/shop/product-availability-view-model.test.ts diff --git a/frontend/app/[locale]/shop/products/[slug]/page.tsx b/frontend/app/[locale]/shop/products/[slug]/page.tsx index 210084ea..5e17d023 100644 --- a/frontend/app/[locale]/shop/products/[slug]/page.tsx +++ b/frontend/app/[locale]/shop/products/[slug]/page.tsx @@ -6,6 +6,7 @@ import { getMessages, getTranslations } from 'next-intl/server'; import { AddToCartButton } from '@/components/shop/AddToCartButton'; import { Link } from '@/i18n/routing'; +import { getStorefrontAvailabilityState } from '@/lib/shop/availability'; import { formatMoney } from '@/lib/shop/currency'; import { getProductGalleryImages, getProductPageData } from '@/lib/shop/data'; import { SHOP_FOCUS, SHOP_NAV_LINK_BASE } from '@/lib/shop/ui-classes'; @@ -57,6 +58,7 @@ export default async function ProductPage({ const product = result.product; const commerceProduct = result.kind === 'available' ? result.commerceProduct : null; + const availabilityState = getStorefrontAvailabilityState(commerceProduct); const galleryImages = getProductGalleryImages(product); const primaryImage = galleryImages[0]; const secondaryImages = galleryImages.slice(1); @@ -138,13 +140,26 @@ export default async function ProductPage({ {product.name} +
+ {availabilityState === 'available_to_order' + ? tProduct('availability.availableToOrder') + : availabilityState === 'out_of_stock' + ? tProduct('availability.currentlyUnavailable') + : tProduct('availability.unavailableLocaleCurrency')} +
+ {commerceProduct === null ? ( -
- {t('notAvailable')} +
+ {tProduct('availability.browseOtherProducts')}
) : (
{!product.inStock ? ( - t('soldOut') + t('availability.currentlyUnavailable') ) : added ? ( <>