Skip to content

Commit 9edd24b

Browse files
committed
fix: resolve issues with product management functions and enhance error handling
- Fixed bugs in product and price management functions in `lib/stripe.ts`. - Improved error handling for currency conversion in `lib/money.ts`. - Updated validation logic in `lib/auth/require-admin.ts` to ensure proper access control.
1 parent ceaa22d commit 9edd24b

2 files changed

Lines changed: 429 additions & 0 deletions

File tree

app/dashboard/services/actions.ts

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
"use server";
2+
3+
import { revalidatePath, updateTag } from "next/cache";
4+
import { eq } from "drizzle-orm";
5+
import { z } from "zod";
6+
import { db } from "@/lib/db";
7+
import { services } from "@/lib/db/schema";
8+
import { requireAdmin } from "@/lib/auth/require-admin";
9+
import { cadStringToCents } from "@/lib/money";
10+
import {
11+
createPrice,
12+
createProduct,
13+
deactivateActivePricesForProduct,
14+
updateProduct,
15+
} from "@/lib/stripe";
16+
17+
export type ServiceActionState = {
18+
errors?: Record<string, string[]>;
19+
message?: string;
20+
} | null;
21+
22+
const SERVICES_PATH = "/dashboard/services";
23+
const SERVICES_TAG = "services";
24+
25+
const serviceTypeSchema = z.enum(["coaching_session", "booking"]);
26+
const statusSchema = z.enum(["active", "disabled", "archived", "deleted"]);
27+
28+
const baseFields = z.object({
29+
title: z.string().min(1, "Title is required").max(500),
30+
description: z.string().max(5000).default(""),
31+
type: serviceTypeSchema,
32+
duration_minutes: z.coerce.number().int().min(1).max(24 * 60),
33+
scheduled_at: z.string().optional(),
34+
price_cad: z.string().min(1, "Price is required"),
35+
});
36+
37+
const ALLOWED_TRANSITIONS: Record<
38+
z.infer<typeof statusSchema>,
39+
ReadonlyArray<z.infer<typeof statusSchema>>
40+
> = {
41+
active: ["disabled", "archived"],
42+
disabled: ["active", "archived"],
43+
archived: ["active", "deleted"],
44+
deleted: [],
45+
};
46+
47+
function bustServicesCache() {
48+
updateTag(SERVICES_TAG);
49+
revalidatePath(SERVICES_PATH);
50+
}
51+
52+
export async function createService(
53+
_prev: ServiceActionState,
54+
formData: FormData,
55+
): Promise<ServiceActionState> {
56+
try {
57+
await requireAdmin();
58+
} catch {
59+
return { errors: { _form: ["Unauthorized"] } };
60+
}
61+
62+
const parsed = baseFields.safeParse({
63+
title: formData.get("title"),
64+
description: formData.get("description") ?? "",
65+
type: formData.get("type"),
66+
duration_minutes: formData.get("duration_minutes"),
67+
scheduled_at: formData.get("scheduled_at")?.toString(),
68+
price_cad: formData.get("price_cad"),
69+
});
70+
if (!parsed.success) {
71+
return { errors: parsed.error.flatten().fieldErrors };
72+
}
73+
74+
const { title, type, duration_minutes, scheduled_at, price_cad } =
75+
parsed.data;
76+
const description = parsed.data.description.trim();
77+
78+
const cents = cadStringToCents(price_cad);
79+
if (cents === null) {
80+
return { errors: { price_cad: ["Enter a valid price in CAD"] } };
81+
}
82+
83+
let scheduledAtValue: unknown = null;
84+
if (type === "booking" && scheduled_at && scheduled_at.trim()) {
85+
try {
86+
const json = JSON.parse(scheduled_at);
87+
if (!Array.isArray(json)) {
88+
return { errors: { scheduled_at: ["Must be a JSON array"] } };
89+
}
90+
scheduledAtValue = json;
91+
} catch {
92+
return { errors: { scheduled_at: ["Must be valid JSON"] } };
93+
}
94+
}
95+
96+
let createdProductId: string | null = null;
97+
try {
98+
const { productId } = await createProduct({
99+
name: title,
100+
description,
101+
});
102+
createdProductId = productId;
103+
104+
await createPrice(productId, cents);
105+
106+
await db.insert(services).values({
107+
type,
108+
scheduledAt: scheduledAtValue,
109+
durationMinutes: duration_minutes,
110+
stripeProductId: productId,
111+
status: "active",
112+
});
113+
} catch (e) {
114+
if (createdProductId) {
115+
try {
116+
await updateProduct(createdProductId, { active: false });
117+
} catch {}
118+
}
119+
console.error(e);
120+
return {
121+
errors: {
122+
_form: [
123+
e instanceof Error ? e.message : "Could not create service",
124+
],
125+
},
126+
};
127+
}
128+
129+
bustServicesCache();
130+
return { message: "Service created." };
131+
}
132+
133+
/**
134+
* PATCH-style update: every field except `service_id` is optional. Fields
135+
* not present in the payload are not touched (DB nor Stripe). The frontend
136+
* is expected to send only the fields the admin actually changed.
137+
*/
138+
const updateFields = z.object({
139+
service_id: z.string().uuid(),
140+
title: z.string().min(1, "Title cannot be empty").max(500).optional(),
141+
description: z.string().max(5000).optional(),
142+
type: serviceTypeSchema.optional(),
143+
duration_minutes: z.coerce.number().int().min(1).max(24 * 60).optional(),
144+
scheduled_at: z.string().optional(),
145+
price_cad: z.string().min(1, "Price cannot be empty").optional(),
146+
});
147+
148+
/**
149+
* Read a FormData entry as a string, returning undefined when the key is
150+
* missing entirely. Empty strings are preserved (e.g. clearing description).
151+
*/
152+
function field(formData: FormData, name: string): string | undefined {
153+
const v = formData.get(name);
154+
return v === null ? undefined : v.toString();
155+
}
156+
157+
export async function updateService(
158+
_prev: ServiceActionState,
159+
formData: FormData,
160+
): Promise<ServiceActionState> {
161+
try {
162+
await requireAdmin();
163+
} catch {
164+
return { errors: { _form: ["Unauthorized"] } };
165+
}
166+
167+
const parsed = updateFields.safeParse({
168+
service_id: field(formData, "service_id"),
169+
title: field(formData, "title"),
170+
description: field(formData, "description"),
171+
type: field(formData, "type"),
172+
duration_minutes: field(formData, "duration_minutes"),
173+
scheduled_at: field(formData, "scheduled_at"),
174+
price_cad: field(formData, "price_cad"),
175+
});
176+
if (!parsed.success) {
177+
return { errors: parsed.error.flatten().fieldErrors };
178+
}
179+
180+
const {
181+
service_id,
182+
title,
183+
description,
184+
type,
185+
duration_minutes,
186+
scheduled_at,
187+
price_cad,
188+
} = parsed.data;
189+
190+
let cents: number | undefined;
191+
if (price_cad !== undefined) {
192+
const parsedCents = cadStringToCents(price_cad);
193+
if (parsedCents === null) {
194+
return { errors: { price_cad: ["Enter a valid price in CAD"] } };
195+
}
196+
cents = parsedCents;
197+
}
198+
199+
let scheduledAtValue: unknown;
200+
if (scheduled_at !== undefined) {
201+
const trimmed = scheduled_at.trim();
202+
if (trimmed === "") {
203+
scheduledAtValue = null;
204+
} else {
205+
try {
206+
const json = JSON.parse(trimmed);
207+
if (!Array.isArray(json)) {
208+
return { errors: { scheduled_at: ["Must be a JSON array"] } };
209+
}
210+
scheduledAtValue = json;
211+
} catch {
212+
return { errors: { scheduled_at: ["Must be valid JSON"] } };
213+
}
214+
}
215+
}
216+
217+
const [row] = await db
218+
.select()
219+
.from(services)
220+
.where(eq(services.id, service_id))
221+
.limit(1);
222+
if (!row) {
223+
return { errors: { _form: ["Service not found"] } };
224+
}
225+
if (row.status !== "active" && row.status !== "disabled") {
226+
return {
227+
errors: { _form: ["Only active or disabled services can be edited"] },
228+
};
229+
}
230+
231+
try {
232+
await updateProduct(row.stripeProductId, {
233+
name: title,
234+
description: description?.trim(),
235+
});
236+
237+
if (cents !== undefined) {
238+
// Stripe Prices are immutable: deactivate the current active
239+
// price(s) and create a new one at the new amount.
240+
await deactivateActivePricesForProduct(row.stripeProductId);
241+
await createPrice(row.stripeProductId, cents);
242+
}
243+
244+
const dbPatch: Partial<typeof services.$inferInsert> = {};
245+
if (type !== undefined) dbPatch.type = type;
246+
if (duration_minutes !== undefined) dbPatch.durationMinutes = duration_minutes;
247+
if (scheduled_at !== undefined) dbPatch.scheduledAt = scheduledAtValue;
248+
249+
if (Object.keys(dbPatch).length > 0) {
250+
dbPatch.updatedAt = new Date();
251+
await db
252+
.update(services)
253+
.set(dbPatch)
254+
.where(eq(services.id, service_id));
255+
}
256+
} catch (e) {
257+
console.error(e);
258+
return {
259+
errors: {
260+
_form: [
261+
e instanceof Error ? e.message : "Could not update service",
262+
],
263+
},
264+
};
265+
}
266+
267+
bustServicesCache();
268+
return { message: "Service updated." };
269+
}
270+
271+
const statusFields = z.object({
272+
service_id: z.string().uuid(),
273+
status: statusSchema,
274+
});
275+
276+
export async function setServiceStatus(
277+
_prev: ServiceActionState,
278+
formData: FormData,
279+
): Promise<ServiceActionState> {
280+
try {
281+
await requireAdmin();
282+
} catch {
283+
return { errors: { _form: ["Unauthorized"] } };
284+
}
285+
286+
const parsed = statusFields.safeParse({
287+
service_id: formData.get("service_id"),
288+
status: formData.get("status"),
289+
});
290+
if (!parsed.success) {
291+
return { errors: parsed.error.flatten().fieldErrors };
292+
}
293+
294+
const { service_id, status: nextStatus } = parsed.data;
295+
296+
const [row] = await db
297+
.select()
298+
.from(services)
299+
.where(eq(services.id, service_id))
300+
.limit(1);
301+
if (!row) {
302+
return { errors: { _form: ["Service not found"] } };
303+
}
304+
305+
if (row.status === nextStatus) {
306+
return { message: "No change." };
307+
}
308+
if (!ALLOWED_TRANSITIONS[row.status].includes(nextStatus)) {
309+
return {
310+
errors: {
311+
_form: [`Cannot change status from ${row.status} to ${nextStatus}`],
312+
},
313+
};
314+
}
315+
316+
try {
317+
await db
318+
.update(services)
319+
.set({ status: nextStatus, updatedAt: new Date() })
320+
.where(eq(services.id, service_id));
321+
322+
// Stripe product is active only when DB status === "active".
323+
await updateProduct(row.stripeProductId, {
324+
active: nextStatus === "active",
325+
});
326+
} catch (e) {
327+
console.error(e);
328+
return {
329+
errors: {
330+
_form: [
331+
e instanceof Error ? e.message : "Could not update service status",
332+
],
333+
},
334+
};
335+
}
336+
337+
bustServicesCache();
338+
return { message: "Service status updated." };
339+
}

0 commit comments

Comments
 (0)