import { query, mutation, internalQuery, internalMutation } from "./_generated/server"; import { paginationOptsValidator } from "convex/server"; import { v } from "convex/values"; import type { Id, Doc } from "./_generated/dataModel"; import * as Users from "./model/users"; import { getProductWithRelations, enrichProducts } from "./model/products"; export const list = query({ args: { paginationOpts: paginationOptsValidator, status: v.optional(v.string()), categoryId: v.optional(v.id("categories")), }, handler: async (ctx, args) => { let q; if (args.status && args.categoryId) { q = ctx.db .query("products") .withIndex("by_status_and_category", (idx) => idx.eq("status", args.status as any).eq("categoryId", args.categoryId!), ); } else if (args.status) { q = ctx.db .query("products") .withIndex("by_status", (idx) => idx.eq("status", args.status as any)); } else if (args.categoryId) { q = ctx.db .query("products") .withIndex("by_category", (idx) => idx.eq("categoryId", args.categoryId!), ); } else { q = ctx.db.query("products"); } const result = await q.paginate(args.paginationOpts); const enrichedPage = await enrichProducts(ctx, result.page as any); return { ...result, page: enrichedPage }; }, }); const LIST_ALL_LIMIT = 500; export const listAll = query({ args: { status: v.optional(v.string()), categoryId: v.optional(v.id("categories")), limit: v.optional(v.number()), }, handler: async (ctx, args) => { const limit = Math.min(args.limit ?? LIST_ALL_LIMIT, 1000); let q; if (args.status && args.categoryId) { q = ctx.db .query("products") .withIndex("by_status_and_category", (idx) => idx.eq("status", args.status as any).eq("categoryId", args.categoryId!), ); } else if (args.status) { q = ctx.db .query("products") .withIndex("by_status", (idx) => idx.eq("status", args.status as any)); } else if (args.categoryId) { q = ctx.db .query("products") .withIndex("by_category", (idx) => idx.eq("categoryId", args.categoryId!), ); } else { q = ctx.db.query("products"); } const page = await q.take(limit); return enrichProducts(ctx, page as any); }, }); const SHOP_LIST_LIMIT = 200; // Filter args: optional brand, tags (product has any), attributes (overlap per dimension). const shopFilterArgsValidator = { brand: v.optional(v.string()), tags: v.optional(v.array(v.string())), attributes: v.optional( v.object({ petSize: v.optional(v.array(v.string())), ageRange: v.optional(v.array(v.string())), specialDiet: v.optional(v.array(v.string())), material: v.optional(v.string()), flavor: v.optional(v.string()), }), ), }; type ShopFilterArgs = { brand?: string; tags?: string[]; attributes?: { petSize?: string[]; ageRange?: string[]; specialDiet?: string[]; material?: string; flavor?: string; }; }; function productMatchesFilters( p: { brand?: string; tags: string[]; attributes?: { petSize?: string[]; ageRange?: string[]; specialDiet?: string[]; material?: string; flavor?: string } }, filters: ShopFilterArgs, ): boolean { if (filters.brand != null && filters.brand !== "" && p.brand !== filters.brand) return false; if (filters.tags != null && filters.tags.length > 0) { const hasAny = (p.tags ?? []).some((t) => filters.tags!.includes(t)); if (!hasAny) return false; } const attrs = filters.attributes; if (attrs) { if (attrs.petSize != null && attrs.petSize.length > 0) { const productVals = p.attributes?.petSize ?? []; if (!attrs.petSize.some((v) => productVals.includes(v))) return false; } if (attrs.ageRange != null && attrs.ageRange.length > 0) { const productVals = p.attributes?.ageRange ?? []; if (!attrs.ageRange.some((v) => productVals.includes(v))) return false; } if (attrs.specialDiet != null && attrs.specialDiet.length > 0) { const productVals = p.attributes?.specialDiet ?? []; if (!attrs.specialDiet.some((v) => productVals.includes(v))) return false; } if (attrs.material != null && attrs.material !== "") { if (p.attributes?.material !== attrs.material) return false; } if (attrs.flavor != null && attrs.flavor !== "") { if (p.attributes?.flavor !== attrs.flavor) return false; } } return true; } export const listActive = query({ args: { categoryId: v.optional(v.id("categories")), limit: v.optional(v.number()), ...shopFilterArgsValidator, }, handler: async (ctx, args) => { const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500); const filters: ShopFilterArgs = { brand: args.brand, tags: args.tags, attributes: args.attributes, }; let q; if (args.categoryId) { q = ctx.db .query("products") .withIndex("by_status_and_category", (idx) => idx.eq("status", "active").eq("categoryId", args.categoryId!), ); } else { q = ctx.db .query("products") .withIndex("by_status", (idx) => idx.eq("status", "active")); } const hasFilters = filters.brand != null || (filters.tags?.length ?? 0) > 0 || filters.attributes != null; const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit; const page = await q.take(takeCount); const filtered = (page as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit); return enrichProducts(ctx, filtered as any); }, }); export const listByRootCategory = query({ args: { rootCategoryId: v.id("categories"), limit: v.optional(v.number()), ...shopFilterArgsValidator, }, handler: async (ctx, args) => { const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500); const filters: ShopFilterArgs = { brand: args.brand, tags: args.tags, attributes: args.attributes, }; const root = await ctx.db.get(args.rootCategoryId); if (!root) return []; const parentCategorySlug = root.slug; const hasFilters = filters.brand != null || (filters.tags?.length ?? 0) > 0 || filters.attributes != null; const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit; const products = await ctx.db .query("products") .withIndex("by_status_and_parent_slug", (q) => q.eq("status", "active").eq("parentCategorySlug", parentCategorySlug), ) .take(takeCount); const filtered = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit); return enrichProducts(ctx, filtered as any); }, }); export const listByTopCategory = query({ args: { topCategorySlug: v.string(), petCategorySlug: v.optional(v.string()), limit: v.optional(v.number()), ...shopFilterArgsValidator, }, handler: async (ctx, args) => { const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500); const filters: ShopFilterArgs = { brand: args.brand, tags: args.tags, attributes: args.attributes, }; const hasFilters = filters.brand != null || (filters.tags?.length ?? 0) > 0 || filters.attributes != null; const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit; let products; if (args.petCategorySlug) { products = await ctx.db .query("products") .withIndex("by_status_and_top_and_parent_slug", (q) => q .eq("status", "active") .eq("topCategorySlug", args.topCategorySlug) .eq("parentCategorySlug", args.petCategorySlug!), ) .take(takeCount); } else { products = await ctx.db .query("products") .withIndex("by_status_and_top_category_slug", (q) => q.eq("status", "active").eq("topCategorySlug", args.topCategorySlug), ) .take(takeCount); } const filteredProducts = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit); return enrichProducts(ctx, filteredProducts as any); }, }); export const listByParentSlug = query({ args: { parentCategorySlug: v.string(), limit: v.optional(v.number()), ...shopFilterArgsValidator, }, handler: async (ctx, args) => { const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500); const filters: ShopFilterArgs = { brand: args.brand, tags: args.tags, attributes: args.attributes, }; const hasFilters = filters.brand != null || (filters.tags?.length ?? 0) > 0 || filters.attributes != null; const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit; const products = await ctx.db .query("products") .withIndex("by_status_and_parent_slug", (q) => q.eq("status", "active").eq("parentCategorySlug", args.parentCategorySlug), ) .take(takeCount); const filtered = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit); return enrichProducts(ctx, filtered as any); }, }); export const listByTag = query({ args: { tag: v.string(), limit: v.optional(v.number()), ...shopFilterArgsValidator, }, handler: async (ctx, args) => { const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500); const filters: ShopFilterArgs = { brand: args.brand, tags: args.tags, attributes: args.attributes, }; const hasFilters = filters.brand != null || (filters.tags?.length ?? 0) > 0 || filters.attributes != null; const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit * 5; const page = await ctx.db .query("products") .withIndex("by_status", (q) => q.eq("status", "active")) .take(takeCount); const filtered = (page as any[]) .filter((p: any) => (p.tags ?? []).includes(args.tag)) .filter((p: any) => productMatchesFilters(p, filters)) .slice(0, limit); return enrichProducts(ctx, filtered as any); }, }); const RECENTLY_ADDED_LIMIT = 100; const RECENTLY_ADDED_SCAN_CEILING = 500; const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; export const listRecentlyAdded = query({ args: { limit: v.optional(v.number()), ...shopFilterArgsValidator, }, handler: async (ctx, args) => { const limit = Math.min(args.limit ?? RECENTLY_ADDED_LIMIT, RECENTLY_ADDED_LIMIT); const filters: ShopFilterArgs = { brand: args.brand, tags: args.tags, attributes: args.attributes, }; const cutoff = Date.now() - THIRTY_DAYS_MS; const collected: any[] = []; let scanned = 0; for await (const p of ctx.db .query("products") .withIndex("by_status", (q: any) => q.eq("status", "active")) .order("desc")) { if (p._creationTime < cutoff) break; if (scanned++ >= RECENTLY_ADDED_SCAN_CEILING) break; if (productMatchesFilters(p as any, filters)) { collected.push(p); if (collected.length >= limit) break; } } return enrichProducts(ctx, collected as any); }, }); export const listByParentAndChildSlug = query({ args: { parentCategorySlug: v.string(), childCategorySlug: v.string(), limit: v.optional(v.number()), ...shopFilterArgsValidator, }, handler: async (ctx, args) => { const limit = Math.min(args.limit ?? SHOP_LIST_LIMIT, 500); const filters: ShopFilterArgs = { brand: args.brand, tags: args.tags, attributes: args.attributes, }; const hasFilters = filters.brand != null || (filters.tags?.length ?? 0) > 0 || filters.attributes != null; const takeCount = hasFilters ? Math.min(2000, limit * 10) : limit; const products = await ctx.db .query("products") .withIndex("by_status_and_parent_and_child_slug", (q) => q .eq("status", "active") .eq("parentCategorySlug", args.parentCategorySlug) .eq("childCategorySlug", args.childCategorySlug), ) .take(takeCount); const filtered = (products as any[]).filter((p: any) => productMatchesFilters(p, filters)).slice(0, limit); return enrichProducts(ctx, filtered as any); }, }); // ─── Filter options (derived from products in scope) ─────────────────────── const FILTER_OPTIONS_PRODUCT_LIMIT = 2000; async function getProductsInScope( ctx: { db: any }, scope: { categoryId?: Id<"categories">; rootCategoryId?: Id<"categories">; parentCategorySlug?: string; childCategorySlug?: string; topCategorySlug?: string; petCategorySlug?: string; tag?: string; recentlyAdded?: boolean; }, ): Promise> { // Resolve which scope is used and fetch raw products (no enrichment needed for aggregation). if (scope.categoryId !== undefined) { return ctx.db .query("products") .withIndex("by_status_and_category", (q: any) => q.eq("status", "active").eq("categoryId", scope.categoryId), ) .take(FILTER_OPTIONS_PRODUCT_LIMIT); } if (scope.rootCategoryId !== undefined) { const root = await ctx.db.get(scope.rootCategoryId); if (!root) return []; return ctx.db .query("products") .withIndex("by_status_and_parent_slug", (q: any) => q.eq("status", "active").eq("parentCategorySlug", root.slug), ) .take(FILTER_OPTIONS_PRODUCT_LIMIT); } if (scope.parentCategorySlug !== undefined && scope.childCategorySlug !== undefined) { return ctx.db .query("products") .withIndex("by_status_and_parent_and_child_slug", (q: any) => q .eq("status", "active") .eq("parentCategorySlug", scope.parentCategorySlug) .eq("childCategorySlug", scope.childCategorySlug), ) .take(FILTER_OPTIONS_PRODUCT_LIMIT); } if (scope.parentCategorySlug !== undefined) { return ctx.db .query("products") .withIndex("by_status_and_parent_slug", (q: any) => q.eq("status", "active").eq("parentCategorySlug", scope.parentCategorySlug), ) .take(FILTER_OPTIONS_PRODUCT_LIMIT); } if (scope.topCategorySlug !== undefined) { if (scope.petCategorySlug) { return ctx.db .query("products") .withIndex("by_status_and_top_and_parent_slug", (q: any) => q .eq("status", "active") .eq("topCategorySlug", scope.topCategorySlug) .eq("parentCategorySlug", scope.petCategorySlug), ) .take(FILTER_OPTIONS_PRODUCT_LIMIT); } return ctx.db .query("products") .withIndex("by_status_and_top_category_slug", (q: any) => q.eq("status", "active").eq("topCategorySlug", scope.topCategorySlug), ) .take(FILTER_OPTIONS_PRODUCT_LIMIT); } if (scope.tag !== undefined) { const all = await ctx.db .query("products") .withIndex("by_status", (q: any) => q.eq("status", "active")) .take(FILTER_OPTIONS_PRODUCT_LIMIT); return all.filter((p: any) => (p.tags ?? []).includes(scope.tag!)); } if (scope.recentlyAdded === true) { const cutoff = Date.now() - THIRTY_DAYS_MS; const results: any[] = []; for await (const p of ctx.db .query("products") .withIndex("by_status", (q: any) => q.eq("status", "active")) .order("desc")) { if (p._creationTime < cutoff) break; results.push(p); if (results.length >= FILTER_OPTIONS_PRODUCT_LIMIT) break; } return results; } // No scope: entire catalog return ctx.db .query("products") .withIndex("by_status", (q: any) => q.eq("status", "active")) .take(FILTER_OPTIONS_PRODUCT_LIMIT); } function aggregateFilterOptions( products: Array<{ brand?: string; tags: string[]; attributes?: { petSize?: string[]; ageRange?: string[]; specialDiet?: string[]; material?: string; flavor?: string } }>, ): { brands: string[]; tags: string[]; attributes: { petSize: string[]; ageRange: string[]; specialDiet: string[]; material: string[]; flavor: string[] }; } { const brandsSet = new Set(); const tagsSet = new Set(); const petSizeSet = new Set(); const ageRangeSet = new Set(); const specialDietSet = new Set(); const materialSet = new Set(); const flavorSet = new Set(); for (const p of products) { if (p.brand != null && p.brand.trim() !== "") brandsSet.add(p.brand); for (const t of p.tags ?? []) if (t != null && String(t).trim() !== "") tagsSet.add(String(t)); const attrs = p.attributes; if (attrs) { for (const v of attrs.petSize ?? []) if (v != null && String(v).trim() !== "") petSizeSet.add(String(v)); for (const v of attrs.ageRange ?? []) if (v != null && String(v).trim() !== "") ageRangeSet.add(String(v)); for (const v of attrs.specialDiet ?? []) if (v != null && String(v).trim() !== "") specialDietSet.add(String(v)); if (attrs.material != null && String(attrs.material).trim() !== "") materialSet.add(String(attrs.material)); if (attrs.flavor != null && String(attrs.flavor).trim() !== "") flavorSet.add(String(attrs.flavor)); } } const sort = (a: string, b: string) => a.localeCompare(b, "en"); return { brands: [...brandsSet].sort(sort), tags: [...tagsSet].sort(sort), attributes: { petSize: [...petSizeSet].sort(sort), ageRange: [...ageRangeSet].sort(sort), specialDiet: [...specialDietSet].sort(sort), material: [...materialSet].sort(sort), flavor: [...flavorSet].sort(sort), }, }; } export const getFilterOptions = query({ args: { categoryId: v.optional(v.id("categories")), rootCategoryId: v.optional(v.id("categories")), parentCategorySlug: v.optional(v.string()), childCategorySlug: v.optional(v.string()), topCategorySlug: v.optional(v.string()), petCategorySlug: v.optional(v.string()), tag: v.optional(v.string()), recentlyAdded: v.optional(v.boolean()), }, handler: async (ctx, args) => { const products = await getProductsInScope(ctx, args); const aggregated = aggregateFilterOptions(products as any); return aggregated; }, }); export const getBySlug = query({ args: { slug: v.string() }, handler: async (ctx, { slug }) => { const product = await ctx.db .query("products") .withIndex("by_slug", (q) => q.eq("slug", slug)) .unique(); if (!product || product.status !== "active") return null; return getProductWithRelations(ctx, product._id); }, }); export const getById = internalQuery({ args: { id: v.id("products") }, handler: async (ctx, { id }) => { return getProductWithRelations(ctx, id); }, }); export const getByIdForAdmin = query({ args: { id: v.id("products") }, handler: async (ctx, { id }) => { await Users.requireAdmin(ctx); return getProductWithRelations(ctx, id); }, }); /** * One-time migration: backfill parentCategorySlug, childCategorySlug, topCategorySlug * from categories. Run once after deploying Phase 1 schema. * Assumes every product's category has parentId set. */ export const backfillProductCategorySlugs = internalMutation({ args: {}, handler: async (ctx) => { const products = await ctx.db.query("products").collect(); let patched = 0; let skipped = 0; for (const product of products) { const category = await ctx.db.get(product.categoryId); if (!category) { skipped += 1; continue; } if (!category.parentId) { skipped += 1; continue; } const parent = await ctx.db.get(category.parentId); if (!parent) { skipped += 1; continue; } const parentCategorySlug = parent.slug; const childCategorySlug = category.slug; const topCategorySlug = category.topCategorySlug !== undefined && category.topCategorySlug !== null ? category.topCategorySlug : undefined; await ctx.db.patch(product._id, { parentCategorySlug, childCategorySlug, ...(topCategorySlug !== undefined && { topCategorySlug }), }); patched += 1; } return { patched, skipped }; }, }); const productStatusValidator = v.union( v.literal("active"), v.literal("draft"), v.literal("archived"), ); export const search = query({ args: { query: v.string(), status: v.optional(productStatusValidator), categoryId: v.optional(v.id("categories")), brand: v.optional(v.string()), limit: v.optional(v.number()), }, handler: async (ctx, args) => { const trimmed = args.query.trim(); if (!trimmed) return []; let searchFilter = (q: any) => { if (args.status !== undefined) q = q.eq("status", args.status); if (args.categoryId !== undefined) q = q.eq("categoryId", args.categoryId); if (args.brand !== undefined) q = q.eq("brand", args.brand); return q.search("name", trimmed); }; const limit = args.limit ?? 24; const results = await ctx.db .query("products") .withSearchIndex("search_products", searchFilter) .take(limit); return enrichProducts(ctx, results as any); }, }); export const searchTypeahead = query({ args: { query: v.string(), parentCategorySlug: v.optional(v.string()), limit: v.optional(v.number()), }, handler: async (ctx, args) => { const trimmed = args.query.trim(); if (trimmed.length < 3) return []; const limit = Math.min(args.limit ?? 8, 20); const searchFilter = (q: any) => { q = q.eq("status", "active"); if (args.parentCategorySlug !== undefined) { q = q.eq("parentCategorySlug", args.parentCategorySlug); } return q.search("name", trimmed); }; const results = await ctx.db .query("products") .withSearchIndex("search_products", searchFilter) .take(limit); return Promise.all( results.map(async (product) => { const firstImage = await ctx.db .query("productImages") .withIndex("by_product", (q) => q.eq("productId", product._id)) .first(); const variants = await ctx.db .query("productVariants") .withIndex("by_product_and_active", (q) => q.eq("productId", product._id).eq("isActive", true), ) .collect(); const minPriceVariant = variants.length > 0 ? variants.reduce((min, v) => (v.price < min.price ? v : min)) : null; return { _id: product._id, name: product.name, slug: product.slug, parentCategorySlug: product.parentCategorySlug, childCategorySlug: product.childCategorySlug, brand: product.brand, imageUrl: firstImage?.url, imageAlt: firstImage?.alt, minPrice: minPriceVariant?.price ?? 0, compareAtPrice: minPriceVariant?.compareAtPrice, averageRating: product.averageRating, reviewCount: product.reviewCount, }; }), ); }, }); export const create = mutation({ args: { name: v.string(), slug: v.string(), description: v.optional(v.string()), shortDescription: v.optional(v.string()), status: v.union( v.literal("active"), v.literal("draft"), v.literal("archived"), ), categoryId: v.id("categories"), brand: v.optional(v.string()), tags: v.array(v.string()), attributes: v.optional( v.object({ petSize: v.optional(v.array(v.string())), ageRange: v.optional(v.array(v.string())), specialDiet: v.optional(v.array(v.string())), material: v.optional(v.string()), flavor: v.optional(v.string()), }), ), seoTitle: v.optional(v.string()), seoDescription: v.optional(v.string()), canonicalSlug: v.optional(v.string()), }, handler: async (ctx, args) => { await Users.requireAdmin(ctx); const category = await ctx.db.get(args.categoryId) as Doc<"categories"> | null; if (!category) throw new Error("Category not found"); const parentCategorySlug = category.parentId ? (await ctx.db.get(category.parentId) as Doc<"categories"> | null)?.slug ?? category.slug : category.slug; const childCategorySlug = category.slug; const topCategorySlug = category.topCategorySlug ?? undefined; return await ctx.db.insert("products", { ...args, parentCategorySlug, childCategorySlug, ...(topCategorySlug !== undefined && { topCategorySlug }), createdAt: Date.now(), updatedAt: Date.now(), }); }, }); export const update = mutation({ args: { id: v.id("products"), name: v.optional(v.string()), slug: v.optional(v.string()), description: v.optional(v.string()), shortDescription: v.optional(v.string()), status: v.optional( v.union( v.literal("active"), v.literal("draft"), v.literal("archived"), ), ), categoryId: v.optional(v.id("categories")), brand: v.optional(v.string()), tags: v.optional(v.array(v.string())), attributes: v.optional( v.object({ petSize: v.optional(v.array(v.string())), ageRange: v.optional(v.array(v.string())), specialDiet: v.optional(v.array(v.string())), material: v.optional(v.string()), flavor: v.optional(v.string()), }), ), seoTitle: v.optional(v.string()), seoDescription: v.optional(v.string()), canonicalSlug: v.optional(v.string()), }, handler: async (ctx, { id, ...updates }) => { await Users.requireAdmin(ctx); const existing = await ctx.db.get(id); if (!existing) throw new Error("Product not found"); const fields: Record = {}; for (const [key, value] of Object.entries(updates)) { if (value !== undefined) fields[key] = value; } const categoryId = fields.categoryId ?? existing.categoryId; const category = await ctx.db.get(categoryId) as Doc<"categories"> | null; if (category) { const parentCategorySlug = category.parentId ? (await ctx.db.get(category.parentId) as Doc<"categories"> | null)?.slug ?? category.slug : category.slug; fields.parentCategorySlug = parentCategorySlug; fields.childCategorySlug = category.slug; if (category.topCategorySlug !== undefined && category.topCategorySlug !== null) { fields.topCategorySlug = category.topCategorySlug; } } fields.updatedAt = Date.now(); await ctx.db.patch(id, fields); return id; }, }); export const archive = mutation({ args: { id: v.id("products") }, handler: async (ctx, { id }) => { await Users.requireAdmin(ctx); const existing = await ctx.db.get(id); if (!existing) throw new Error("Product not found"); await ctx.db.patch(id, { status: "archived" }); return id; }, }); // ─── Product images ─────────────────────────────────────────────────────── export const addImage = mutation({ args: { productId: v.id("products"), url: v.string(), alt: v.optional(v.string()), position: v.number(), }, handler: async (ctx, args) => { await Users.requireAdmin(ctx); const product = await ctx.db.get(args.productId); if (!product) throw new Error("Product not found"); return await ctx.db.insert("productImages", { productId: args.productId, url: args.url, alt: args.alt, position: args.position, }); }, }); export const deleteImage = mutation({ args: { id: v.id("productImages") }, handler: async (ctx, { id }) => { await Users.requireAdmin(ctx); const image = await ctx.db.get(id); if (!image) throw new Error("Image not found"); await ctx.db.delete(id); return id; }, }); export const reorderImages = mutation({ args: { updates: v.array( v.object({ id: v.id("productImages"), position: v.number(), }), ), }, handler: async (ctx, { updates }) => { await Users.requireAdmin(ctx); for (const { id, position } of updates) { const image = await ctx.db.get(id); if (!image) throw new Error(`Image not found: ${id}`); await ctx.db.patch(id, { position }); } }, }); // ─── Product variants ────────────────────────────────────────────────────── const variantAttributesValidator = v.optional( v.object({ size: v.optional(v.string()), flavor: v.optional(v.string()), color: v.optional(v.string()), }), ); export const addVariant = mutation({ args: { productId: v.id("products"), name: v.string(), sku: v.string(), price: v.number(), compareAtPrice: v.optional(v.number()), stockQuantity: v.number(), attributes: variantAttributesValidator, isActive: v.boolean(), weight: v.optional(v.number()), weightUnit: v.optional( v.union( v.literal("g"), v.literal("kg"), v.literal("lb"), v.literal("oz"), ), ), }, handler: async (ctx, args) => { await Users.requireAdmin(ctx); const product = await ctx.db.get(args.productId); if (!product) throw new Error("Product not found"); return await ctx.db.insert("productVariants", { productId: args.productId, name: args.name, sku: args.sku, price: args.price, compareAtPrice: args.compareAtPrice, stockQuantity: args.stockQuantity, attributes: args.attributes, isActive: args.isActive, weight: args.weight ?? 0, weightUnit: args.weightUnit ?? "g", }); }, }); export const updateVariant = mutation({ args: { id: v.id("productVariants"), price: v.optional(v.number()), compareAtPrice: v.optional(v.number()), stockQuantity: v.optional(v.number()), isActive: v.optional(v.boolean()), }, handler: async (ctx, { id, ...updates }) => { await Users.requireAdmin(ctx); const variant = await ctx.db.get(id); if (!variant) throw new Error("Variant not found"); const fields: Record = {}; for (const [key, value] of Object.entries(updates)) { if (value !== undefined) fields[key] = value; } if (Object.keys(fields).length > 0) { await ctx.db.patch(id, fields); } return id; }, }); export const deleteVariant = mutation({ args: { id: v.id("productVariants") }, handler: async (ctx, { id }) => { await Users.requireAdmin(ctx); const variant = await ctx.db.get(id); if (!variant) throw new Error("Variant not found"); const orderItemsWithVariant = await ctx.db .query("orderItems") .filter((q) => q.eq(q.field("variantId"), id)) .collect(); if (orderItemsWithVariant.length > 0) { await ctx.db.patch(id, { isActive: false }); } else { await ctx.db.delete(id); } return id; }, });