Files
the-pet-loft/convex/wishlists.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

184 lines
5.4 KiB
TypeScript

import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import * as Users from "./model/users";
import { enrichProducts } from "./model/products";
import type { Id } from "./_generated/dataModel";
export const list = query({
args: {},
handler: async (ctx) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const rows = await ctx.db
.query("wishlists")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.collect();
if (rows.length === 0) return [];
const productIds = [...new Set(rows.map((r) => r.productId))];
const products = (
await Promise.all(productIds.map((id) => ctx.db.get(id)))
).filter(Boolean) as Awaited<ReturnType<typeof ctx.db.get>>[];
const enriched = await enrichProducts(ctx, products);
const productMap = new Map(
enriched.map((p) => [p._id, p]),
);
return rows.map((row) => {
const product = productMap.get(row.productId);
const variant = row.variantId && product?.variants
? product.variants.find((v: { _id: Id<"productVariants"> }) => v._id === row.variantId)
: undefined;
return { ...row, product, variant };
});
},
});
function findExistingEntry(
rows: { variantId?: Id<"productVariants"> }[],
variantId?: Id<"productVariants">,
) {
return rows.find((r) => {
if (variantId === undefined && r.variantId === undefined) return true;
return r.variantId === variantId;
});
}
export const add = mutation({
args: {
productId: v.id("products"),
variantId: v.optional(v.id("productVariants")),
notifyOnPriceDrop: v.optional(v.boolean()),
notifyOnBackInStock: v.optional(v.boolean()),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const existing = await ctx.db
.query("wishlists")
.withIndex("by_user_and_product", (q) =>
q.eq("userId", user._id).eq("productId", args.productId),
)
.collect();
const found = findExistingEntry(existing, args.variantId);
if (found)
return { id: (found as { _id: Id<"wishlists"> })._id, alreadyExisted: true };
let priceWhenAdded = 0;
if (args.variantId) {
const variant = await ctx.db.get(args.variantId);
if (variant && variant.productId === args.productId) {
priceWhenAdded = variant.price;
}
} else {
const variants = await ctx.db
.query("productVariants")
.withIndex("by_product_and_active", (q) =>
q.eq("productId", args.productId).eq("isActive", true),
)
.first();
if (variants) priceWhenAdded = variants.price;
}
const id = await ctx.db.insert("wishlists", {
userId: user._id,
productId: args.productId,
variantId: args.variantId,
addedAt: Date.now(),
notifyOnPriceDrop: args.notifyOnPriceDrop ?? false,
notifyOnBackInStock: args.notifyOnBackInStock ?? false,
priceWhenAdded,
});
return { id, alreadyExisted: false };
},
});
export const remove = mutation({
args: { id: v.id("wishlists") },
handler: async (ctx, { id }) => {
const doc = await ctx.db.get(id);
if (!doc) throw new Error("Wishlist item not found");
await Users.requireOwnership(ctx, doc.userId);
await ctx.db.delete(id);
},
});
export const toggle = mutation({
args: {
productId: v.id("products"),
variantId: v.optional(v.id("productVariants")),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const existing = await ctx.db
.query("wishlists")
.withIndex("by_user_and_product", (q) =>
q.eq("userId", user._id).eq("productId", args.productId),
)
.collect();
const found = findExistingEntry(existing, args.variantId) as
| { _id: Id<"wishlists"> }
| undefined;
if (found) {
await ctx.db.delete(found._id);
return { removed: true };
}
const id = await ctx.db.insert("wishlists", {
userId: user._id,
productId: args.productId,
variantId: args.variantId,
addedAt: Date.now(),
notifyOnPriceDrop: false,
notifyOnBackInStock: false,
priceWhenAdded: await (async () => {
if (args.variantId) {
const v = await ctx.db.get(args.variantId);
return v && v.productId === args.productId ? v.price : 0;
}
const first = await ctx.db
.query("productVariants")
.withIndex("by_product_and_active", (q) =>
q.eq("productId", args.productId).eq("isActive", true),
)
.first();
return first?.price ?? 0;
})(),
});
return { added: true, id };
},
});
export const count = query({
args: {},
handler: async (ctx) => {
const user = await Users.getCurrentUser(ctx);
if (!user) return 0;
const rows = await ctx.db
.query("wishlists")
.withIndex("by_user", (q) => q.eq("userId", user._id))
.collect();
return rows.length;
},
});
export const isWishlisted = query({
args: {
productId: v.id("products"),
variantId: v.optional(v.id("productVariants")),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const rows = await ctx.db
.query("wishlists")
.withIndex("by_user_and_product", (q) =>
q.eq("userId", user._id).eq("productId", args.productId),
)
.collect();
return !!findExistingEntry(rows, args.variantId);
},
});