From c8ebbf8139bc19d2910e111b257ed67ea29866bb Mon Sep 17 00:00:00 2001 From: Jean-Luc Makiola Date: Fri, 10 Apr 2026 10:58:36 +0200 Subject: [PATCH] feat(25-01): Zod schemas, upsert service functions, passing tests - Add upsertGlobalItemSchema and bulkUpsertGlobalItemsSchema to schemas.ts - Add UpsertGlobalItemInput and BulkUpsertGlobalItemsInput types to types.ts - Implement upsertGlobalItem with onConflictDoUpdate and tag sync - Implement bulkUpsertGlobalItems processing array in single transaction - Fix migration 0003 to only add new columns + unique constraint - All 21 tests pass including 8 new upsert operation tests --- drizzle-pg/0003_loving_serpent_society.sql | 14 +- src/server/services/global-item.service.ts | 180 +++++++++++++++++++++ src/shared/schemas.ts | 19 +++ src/shared/types.ts | 6 + tests/services/global-item.service.test.ts | 8 +- 5 files changed, 212 insertions(+), 15 deletions(-) diff --git a/drizzle-pg/0003_loving_serpent_society.sql b/drizzle-pg/0003_loving_serpent_society.sql index d00bbf0..4510e6e 100644 --- a/drizzle-pg/0003_loving_serpent_society.sql +++ b/drizzle-pg/0003_loving_serpent_society.sql @@ -1,16 +1,4 @@ -CREATE TABLE "global_item_tags" ( - "global_item_id" integer NOT NULL, - "tag_id" integer NOT NULL, - CONSTRAINT "global_item_tags_global_item_id_tag_id_pk" PRIMARY KEY("global_item_id","tag_id") -); ---> statement-breakpoint ALTER TABLE "global_items" ADD COLUMN "source_url" text;--> statement-breakpoint ALTER TABLE "global_items" ADD COLUMN "image_credit" text;--> statement-breakpoint ALTER TABLE "global_items" ADD COLUMN "image_source_url" text;--> statement-breakpoint -ALTER TABLE "oauth_codes" ADD COLUMN "user_id" integer NOT NULL;--> statement-breakpoint -ALTER TABLE "global_item_tags" ADD CONSTRAINT "global_item_tags_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "global_item_tags" ADD CONSTRAINT "global_item_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "items" ADD CONSTRAINT "items_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "oauth_codes" ADD CONSTRAINT "oauth_codes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "thread_candidates" ADD CONSTRAINT "thread_candidates_global_item_id_global_items_id_fk" FOREIGN KEY ("global_item_id") REFERENCES "public"."global_items"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint -ALTER TABLE "global_items" ADD CONSTRAINT "global_items_brand_model_unique" UNIQUE("brand","model"); \ No newline at end of file +ALTER TABLE "global_items" ADD CONSTRAINT "global_items_brand_model_unique" UNIQUE("brand","model"); diff --git a/src/server/services/global-item.service.ts b/src/server/services/global-item.service.ts index de4152f..dc8e97f 100644 --- a/src/server/services/global-item.service.ts +++ b/src/server/services/global-item.service.ts @@ -4,6 +4,7 @@ import { db as prodDb } from "../../db/index.ts"; import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts"; type Db = typeof prodDb; +type TxDb = Parameters[0]>[0]; /** * Search global items by brand or model and/or tag names. @@ -71,3 +72,182 @@ export async function getGlobalItemWithOwnerCount(db: Db = prodDb, id: number) { return { ...item, ownerCount: result?.ownerCount ?? 0 }; } + +/** + * Sync tags for a global item: delete existing, re-insert provided tag names. + * Creates tags that don't exist yet (create-if-not-exists). + */ +async function syncGlobalItemTags( + tx: TxDb, + globalItemId: number, + tagNames: string[], +) { + await tx + .delete(globalItemTags) + .where(eq(globalItemTags.globalItemId, globalItemId)); + + for (const name of tagNames) { + const [tag] = await tx + .insert(tags) + .values({ name }) + .onConflictDoUpdate({ target: tags.name, set: { name } }) + .returning({ id: tags.id }); + + await tx.insert(globalItemTags).values({ globalItemId, tagId: tag.id }); + } +} + +/** + * Upsert a single global item by (brand, model). + * Creates if not exists, updates all non-key fields if exists. + * Tag sync: provided → sync; undefined → leave untouched; [] → clear all tags. + */ +export async function upsertGlobalItem( + db: Db, + data: { + brand: string; + model: string; + category?: string; + weightGrams?: number; + priceCents?: number; + imageUrl?: string; + description?: string; + sourceUrl?: string; + imageCredit?: string; + imageSourceUrl?: string; + tags?: string[]; + }, +) { + return await db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: globalItems.id }) + .from(globalItems) + .where( + and( + eq(globalItems.brand, data.brand), + eq(globalItems.model, data.model), + ), + ); + + const { tags: tagNames, ...itemData } = data; + + const [item] = await tx + .insert(globalItems) + .values({ + brand: itemData.brand, + model: itemData.model, + category: itemData.category ?? null, + weightGrams: itemData.weightGrams ?? null, + priceCents: itemData.priceCents ?? null, + imageUrl: itemData.imageUrl ?? null, + description: itemData.description ?? null, + sourceUrl: itemData.sourceUrl ?? null, + imageCredit: itemData.imageCredit ?? null, + imageSourceUrl: itemData.imageSourceUrl ?? null, + }) + .onConflictDoUpdate({ + target: [globalItems.brand, globalItems.model], + set: { + category: itemData.category ?? null, + weightGrams: itemData.weightGrams ?? null, + priceCents: itemData.priceCents ?? null, + imageUrl: itemData.imageUrl ?? null, + description: itemData.description ?? null, + sourceUrl: itemData.sourceUrl ?? null, + imageCredit: itemData.imageCredit ?? null, + imageSourceUrl: itemData.imageSourceUrl ?? null, + }, + }) + .returning(); + + if (tagNames !== undefined) { + await syncGlobalItemTags(tx, item.id, tagNames); + } + + return { item, created: !existing }; + }); +} + +/** + * Bulk upsert global items in a single transaction. + * Returns { created, updated, items } with accurate counts. + * Rolls back entirely if any item fails. + */ +export async function bulkUpsertGlobalItems( + db: Db, + itemsData: Array<{ + brand: string; + model: string; + category?: string; + weightGrams?: number; + priceCents?: number; + imageUrl?: string; + description?: string; + sourceUrl?: string; + imageCredit?: string; + imageSourceUrl?: string; + tags?: string[]; + }>, +) { + return await db.transaction(async (tx) => { + let created = 0; + let updated = 0; + const resultItems = []; + + for (const data of itemsData) { + const [existing] = await tx + .select({ id: globalItems.id }) + .from(globalItems) + .where( + and( + eq(globalItems.brand, data.brand), + eq(globalItems.model, data.model), + ), + ); + + const { tags: tagNames, ...itemData } = data; + + const [item] = await tx + .insert(globalItems) + .values({ + brand: itemData.brand, + model: itemData.model, + category: itemData.category ?? null, + weightGrams: itemData.weightGrams ?? null, + priceCents: itemData.priceCents ?? null, + imageUrl: itemData.imageUrl ?? null, + description: itemData.description ?? null, + sourceUrl: itemData.sourceUrl ?? null, + imageCredit: itemData.imageCredit ?? null, + imageSourceUrl: itemData.imageSourceUrl ?? null, + }) + .onConflictDoUpdate({ + target: [globalItems.brand, globalItems.model], + set: { + category: itemData.category ?? null, + weightGrams: itemData.weightGrams ?? null, + priceCents: itemData.priceCents ?? null, + imageUrl: itemData.imageUrl ?? null, + description: itemData.description ?? null, + sourceUrl: itemData.sourceUrl ?? null, + imageCredit: itemData.imageCredit ?? null, + imageSourceUrl: itemData.imageSourceUrl ?? null, + }, + }) + .returning(); + + if (tagNames !== undefined) { + await syncGlobalItemTags(tx, item.id, tagNames); + } + + if (existing) { + updated++; + } else { + created++; + } + resultItems.push(item); + } + + return { created, updated, items: resultItems }; + }); +} diff --git a/src/shared/schemas.ts b/src/shared/schemas.ts index 720cc7a..fd5e487 100644 --- a/src/shared/schemas.ts +++ b/src/shared/schemas.ts @@ -102,6 +102,25 @@ export const searchGlobalItemsSchema = z.object({ tags: z.string().optional(), }); +// Catalog upsert schemas +export const upsertGlobalItemSchema = z.object({ + brand: z.string().min(1, "Brand is required"), + model: z.string().min(1, "Model is required"), + category: z.string().optional(), + weightGrams: z.number().nonnegative().optional(), + priceCents: z.number().int().nonnegative().optional(), + imageUrl: z.string().url().optional().or(z.literal("")), + description: z.string().optional(), + sourceUrl: z.string().url().optional().or(z.literal("")), + imageCredit: z.string().optional(), + imageSourceUrl: z.string().url().optional().or(z.literal("")), + tags: z.array(z.string().min(1).max(100)).max(20).optional(), +}); + +export const bulkUpsertGlobalItemsSchema = z.object({ + items: z.array(upsertGlobalItemSchema).min(1).max(100), +}); + // Profile schemas export const updateProfileSchema = z.object({ displayName: z.string().max(100).optional(), diff --git a/src/shared/types.ts b/src/shared/types.ts index a27d75b..9f230e8 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -11,6 +11,7 @@ import type { threads, } from "../db/schema.ts"; import type { + bulkUpsertGlobalItemsSchema, createCandidateSchema, createCategorySchema, createItemSchema, @@ -27,6 +28,7 @@ import type { updateProfileSchema, updateSetupSchema, updateThreadSchema, + upsertGlobalItemSchema, } from "./schemas.ts"; // Types inferred from Zod schemas @@ -50,6 +52,10 @@ export type UpdateClassification = z.infer; // Global item types export type SearchGlobalItems = z.infer; export type UpdateProfile = z.infer; +export type UpsertGlobalItemInput = z.infer; +export type BulkUpsertGlobalItemsInput = z.infer< + typeof bulkUpsertGlobalItemsSchema +>; // Types inferred from Drizzle schema export type Item = typeof items.$inferSelect; diff --git a/tests/services/global-item.service.test.ts b/tests/services/global-item.service.test.ts index 6433b6d..99093e7 100644 --- a/tests/services/global-item.service.test.ts +++ b/tests/services/global-item.service.test.ts @@ -312,9 +312,13 @@ describe("Global Item Service", () => { imageSourceUrl: "https://apidura.com/images/handlebar-pack.jpg", }); - expect(result.item.sourceUrl).toBe("https://apidura.com/shop/handlebar-pack/"); + expect(result.item.sourceUrl).toBe( + "https://apidura.com/shop/handlebar-pack/", + ); expect(result.item.imageCredit).toBe("Apidura Ltd"); - expect(result.item.imageSourceUrl).toBe("https://apidura.com/images/handlebar-pack.jpg"); + expect(result.item.imageSourceUrl).toBe( + "https://apidura.com/images/handlebar-pack.jpg", + ); }); it("upsertGlobalItem with tags creates tags and links them", async () => {