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

141 lines
3.5 KiB
TypeScript

import {
mutation,
query,
internalMutation,
internalQuery,
} from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
import { v } from "convex/values";
import * as Users from "./model/users";
export const store = mutation({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
const existing = await ctx.db
.query("users")
.withIndex("by_external_id", (q) =>
q.eq("externalId", identity.subject),
)
.unique();
if (existing) {
if (existing.name !== identity.name) {
await ctx.db.patch(existing._id, {
name: identity.name ?? existing.name,
});
}
return existing._id;
}
return await ctx.db.insert("users", {
name: identity.name ?? "Anonymous",
email: identity.email ?? "",
role: "customer",
externalId: identity.subject,
avatarUrl: identity.pictureUrl ?? undefined,
});
},
});
export const current = query({
args: {},
handler: async (ctx) => Users.getCurrentUser(ctx),
});
export const updateProfile = mutation({
args: {
name: v.optional(v.string()),
phone: v.optional(v.string()),
avatarUrl: v.optional(v.string()),
},
handler: async (ctx, args) => {
const user = await Users.getCurrentUserOrThrow(ctx);
const patch: { name?: string; phone?: string; avatarUrl?: string } = {};
if (args.name !== undefined) patch.name = args.name;
if (args.phone !== undefined) patch.phone = args.phone;
if (args.avatarUrl !== undefined) patch.avatarUrl = args.avatarUrl;
if (Object.keys(patch).length === 0) return user._id;
await ctx.db.patch(user._id, patch);
return user._id;
},
});
export const listCustomers = query({
args: {
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
await Users.requireAdmin(ctx);
return await ctx.db
.query("users")
.withIndex("by_role", (q) => q.eq("role", "customer"))
.order("desc")
.paginate(args.paginationOpts);
},
});
export const upsertFromClerk = internalMutation({
args: {
externalId: v.string(),
name: v.string(),
email: v.string(),
avatarUrl: v.optional(v.string()),
},
handler: async (ctx, args) => {
const existing = await ctx.db
.query("users")
.withIndex("by_external_id", (q) =>
q.eq("externalId", args.externalId),
)
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
name: args.name,
email: args.email,
avatarUrl: args.avatarUrl,
});
} else {
await ctx.db.insert("users", {
...args,
role: "customer",
});
}
},
});
export const deleteFromClerk = internalMutation({
args: { externalId: v.string() },
handler: async (ctx, { externalId }) => {
const user = await ctx.db
.query("users")
.withIndex("by_external_id", (q) => q.eq("externalId", externalId))
.unique();
if (user) await ctx.db.delete(user._id);
},
});
export const getById = internalQuery({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
const user = await ctx.db.get(args.userId);
if (!user) throw new Error("User not found");
return user;
},
});
export const setStripeCustomerId = internalMutation({
args: {
userId: v.id("users"),
stripeCustomerId: v.string(),
},
handler: async (ctx, args) => {
await ctx.db.patch(args.userId, {
stripeCustomerId: args.stripeCustomerId,
});
},
});