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>
This commit is contained in:
929
convex/products.ts
Normal file
929
convex/products.ts
Normal file
@@ -0,0 +1,929 @@
|
||||
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;
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user