diff --git a/app/(authenticated)/webinars/actions.ts b/app/(authenticated)/webinars/actions.ts new file mode 100644 index 0000000..5800eb6 --- /dev/null +++ b/app/(authenticated)/webinars/actions.ts @@ -0,0 +1,177 @@ +"use server"; + +import { updateTag } from "next/cache"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { webinars } from "@/lib/db/schema"; +import { requireAdmin } from "@/lib/auth/require-admin"; +import { + createWebinarSchema, + deleteWebinarSchema, + parseBooleanField, + updateWebinarSchema, +} from "./schema"; + +export type WebinarActionState = { + errors?: Record; + message?: string; +} | null; + +const WEBINARS_TAG = "webinars"; + +function field(formData: FormData, name: string): string | undefined { + const value = formData.get(name); + return value === null ? undefined : value.toString(); +} + +function invalidateWebinars() { + updateTag(WEBINARS_TAG); +} + +function unauthorized(): WebinarActionState { + return { errors: { _form: ["Unauthorized"] } }; +} + +export async function createWebinar( + _prev: WebinarActionState, + formData: FormData, +): Promise { + try { + await requireAdmin(); + } catch { + return unauthorized(); + } + + const parsed = createWebinarSchema.safeParse({ + title: field(formData, "title"), + description: field(formData, "description"), + tier: field(formData, "tier"), + duration_minutes: field(formData, "duration_minutes"), + youtube_url: field(formData, "youtube_url") || undefined, + is_active: field(formData, "is_active"), + ? parseBooleanField(formData.get("is_active")) + : true, + }); + + if (!parsed.success) { + return { errors: parsed.error.flatten().fieldErrors }; + } + + try { + await db.insert(webinars).values({ + title: parsed.data.title, + description: parsed.data.description || null, + tier: parsed.data.tier, + durationMinutes: parsed.data.duration_minutes, + youtubeUrl: parsed.data.youtube_url || null, + isActive: parsed.data.is_active !== "false", + }); + } catch (error) { + console.error(error); + return { errors: { _form: ["Could not create webinar"] } }; + } + + invalidateWebinars(); + return { message: "Webinar created." }; +} + +export async function updateWebinar( + _prev: WebinarActionState, + formData: FormData, +): Promise { + try { + await requireAdmin(); + } catch { + return unauthorized(); + } + + const parsed = updateWebinarSchema.safeParse({ + webinar_id: field(formData, "webinar_id"), + title: formData.has("title") ? field(formData, "title") : undefined, + description: formData.has("description") + ? (field(formData, "description") ?? "") + : undefined, + tier: formData.has("tier") ? field(formData, "tier") : undefined, + duration_minutes: formData.has("duration_minutes") + ? field(formData, "duration_minutes") + : undefined, + youtube_url: formData.has("youtube_url") + ? (field(formData, "youtube_url") ?? "") + : undefined, + is_active: formData.has("is_active") + ? parseBooleanField(formData.get("is_active")) + : undefined, + }); + + if (!parsed.success) { + return { errors: parsed.error.flatten().fieldErrors }; + } + + + + const patch: Partial = { + updatedAt: new Date(), + }; + if (parsed.data.title !== undefined) patch.title = parsed.data.title; + if (parsed.data.description !== undefined) { + patch.description = parsed.data.description || null; + } + if (parsed.data.tier !== undefined) patch.tier = parsed.data.tier; + if (parsed.data.duration_minutes !== undefined) { + patch.durationMinutes = parsed.data.duration_minutes; + } + if (parsed.data.youtube_url !== undefined) { + patch.youtubeUrl = parsed.data.youtube_url || null; + } + if (parsed.data.is_active !== undefined) { + patch.isActive = parsed.data.is_active === "true"; + } + + const updated = await db + .update(webinars) + .set(patch) + .where(eq(webinars.id, parsed.data.webinar_id)) + .returning({ id: webinars.id }); + + if (!updated.length) return { errors: { _form: ["Webinar not found"] } }; + + invalidateWebinars(); + return { message: "Webinar updated." }; +} + +export async function deleteWebinar( + _prev: WebinarActionState, + formData: FormData, +): Promise { + try { + await requireAdmin(); + } catch { + return unauthorized(); + } + + const parsed = deleteWebinarSchema.safeParse({ + webinar_id: field(formData, "webinar_id"), + }); + + if (!parsed.success) { + return { errors: parsed.error.flatten().fieldErrors }; + } + + let deleted; + try { + deleted = await db + .delete(webinars) + .where(eq(webinars.id, parsed.data.webinar_id)) + .returning({ id: webinars.id }); + } catch (error) { + console.error(error); + return { errors: { _form: ["Could not delete webinar"] } }; + } + + if (!deleted.length) { + return { errors: { _form: ["Webinar not found"] } }; + } + + invalidateWebinars(); + return { message: "Webinar deleted." }; +} diff --git a/app/(authenticated)/webinars/schema.ts b/app/(authenticated)/webinars/schema.ts new file mode 100644 index 0000000..fc6deb3 --- /dev/null +++ b/app/(authenticated)/webinars/schema.ts @@ -0,0 +1,52 @@ +import { z } from "zod"; + +export const webinarTierSchema = z.enum(["free", "premium"]); + +const youtubeUrlSchema = z + .string() + .url("Enter a valid YouTube URL") + .refine( + (value) => { + try { + const hostname = new URL(value).hostname.toLowerCase(); + return [ + "youtube.com", + "www.youtube.com", + "youtu.be", + "www.youtu.be", + ].includes(hostname); + } catch { + return false; + } + }, + { message: "URL must be a YouTube URL" }, + ); + +const durationSchema = z.coerce + .number() + .int("Duration must be a whole number") + .min(1, "Duration must be at least 1 minute") + .max(24 * 60, "Duration cannot exceed 24 hours"); + +export const createWebinarSchema = z.object({ + title: z.string().trim().min(1, "Title is required").max(500), + description: z.string().trim().max(5000).optional(), + tier: webinarTierSchema, + duration_minutes: durationSchema, + youtube_url: youtubeUrlSchema.optional(), + is_active: z.enum(["true", "false"]).optional(), +}); + +export const updateWebinarSchema = z.object({ + webinar_id: z.string().uuid("Invalid webinar"), + title: z.string().trim().min(1, "Title cannot be empty").max(500).optional(), + description: z.string().trim().max(5000).optional(), + tier: webinarTierSchema.optional(), + duration_minutes: durationSchema.optional(), + youtube_url: z.union([youtubeUrlSchema, z.literal("")]).optional(), + is_active: z.enum(["true", "false"]).optional(), +}); + +export const deleteWebinarSchema = z.object({ + webinar_id: z.string().uuid("Invalid webinar"), +});