Files
the-pet-loft/convex/products.ts
ianshaloom cc15338ad9 feat: initial commit — storefront, convex backend, and shared packages
Completes the first milestone of The Pet Loft ecommerce platform:
- apps/storefront: full customer-facing Next.js app with HeroUI (cart,
  checkout, orders, wishlist, product detail, shop, search, auth)
- convex/: serverless backend with schema, queries, mutations, actions,
  HTTP routes, Stripe/Shippo integrations, and co-located tests
- packages/types, packages/utils, packages/convex: shared workspace packages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 09:31:18 +03:00

930 lines
29 KiB
TypeScript

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<Array<{ brand?: string; tags: string[]; attributes?: { petSize?: string[]; ageRange?: string[]; specialDiet?: string[]; material?: string; flavor?: string } }>> {
// 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<string>();
const tagsSet = new Set<string>();
const petSizeSet = new Set<string>();
const ageRangeSet = new Set<string>();
const specialDietSet = new Set<string>();
const materialSet = new Set<string>();
const flavorSet = new Set<string>();
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);
},
});
/**
* 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()),
status: v.union(
v.literal("active"),
v.literal("draft"),
v.literal("archived"),
),
categoryId: v.id("categories"),
tags: v.array(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 }),
});
},
});
export const update = mutation({
args: {
id: v.id("products"),
name: v.optional(v.string()),
slug: v.optional(v.string()),
description: v.optional(v.string()),
status: v.optional(
v.union(
v.literal("active"),
v.literal("draft"),
v.literal("archived"),
),
),
categoryId: v.optional(v.id("categories")),
tags: v.optional(v.array(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<string, any> = {};
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;
}
}
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<string, unknown> = {};
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;
},
});