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
This commit is contained in:
@@ -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 "source_url" text;--> statement-breakpoint
|
||||||
ALTER TABLE "global_items" ADD COLUMN "image_credit" 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 "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");
|
ALTER TABLE "global_items" ADD CONSTRAINT "global_items_brand_model_unique" UNIQUE("brand","model");
|
||||||
@@ -4,6 +4,7 @@ import { db as prodDb } from "../../db/index.ts";
|
|||||||
import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts";
|
import { globalItems, globalItemTags, items, tags } from "../../db/schema.ts";
|
||||||
|
|
||||||
type Db = typeof prodDb;
|
type Db = typeof prodDb;
|
||||||
|
type TxDb = Parameters<Parameters<Db["transaction"]>[0]>[0];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search global items by brand or model and/or tag names.
|
* 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 };
|
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -102,6 +102,25 @@ export const searchGlobalItemsSchema = z.object({
|
|||||||
tags: z.string().optional(),
|
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
|
// Profile schemas
|
||||||
export const updateProfileSchema = z.object({
|
export const updateProfileSchema = z.object({
|
||||||
displayName: z.string().max(100).optional(),
|
displayName: z.string().max(100).optional(),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
threads,
|
threads,
|
||||||
} from "../db/schema.ts";
|
} from "../db/schema.ts";
|
||||||
import type {
|
import type {
|
||||||
|
bulkUpsertGlobalItemsSchema,
|
||||||
createCandidateSchema,
|
createCandidateSchema,
|
||||||
createCategorySchema,
|
createCategorySchema,
|
||||||
createItemSchema,
|
createItemSchema,
|
||||||
@@ -27,6 +28,7 @@ import type {
|
|||||||
updateProfileSchema,
|
updateProfileSchema,
|
||||||
updateSetupSchema,
|
updateSetupSchema,
|
||||||
updateThreadSchema,
|
updateThreadSchema,
|
||||||
|
upsertGlobalItemSchema,
|
||||||
} from "./schemas.ts";
|
} from "./schemas.ts";
|
||||||
|
|
||||||
// Types inferred from Zod schemas
|
// Types inferred from Zod schemas
|
||||||
@@ -50,6 +52,10 @@ export type UpdateClassification = z.infer<typeof updateClassificationSchema>;
|
|||||||
// Global item types
|
// Global item types
|
||||||
export type SearchGlobalItems = z.infer<typeof searchGlobalItemsSchema>;
|
export type SearchGlobalItems = z.infer<typeof searchGlobalItemsSchema>;
|
||||||
export type UpdateProfile = z.infer<typeof updateProfileSchema>;
|
export type UpdateProfile = z.infer<typeof updateProfileSchema>;
|
||||||
|
export type UpsertGlobalItemInput = z.infer<typeof upsertGlobalItemSchema>;
|
||||||
|
export type BulkUpsertGlobalItemsInput = z.infer<
|
||||||
|
typeof bulkUpsertGlobalItemsSchema
|
||||||
|
>;
|
||||||
|
|
||||||
// Types inferred from Drizzle schema
|
// Types inferred from Drizzle schema
|
||||||
export type Item = typeof items.$inferSelect;
|
export type Item = typeof items.$inferSelect;
|
||||||
|
|||||||
@@ -312,9 +312,13 @@ describe("Global Item Service", () => {
|
|||||||
imageSourceUrl: "https://apidura.com/images/handlebar-pack.jpg",
|
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.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 () => {
|
it("upsertGlobalItem with tags creates tags and links them", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user