From 3f0984c68525d6df980f086b2add3a7eb9e7f871 Mon Sep 17 00:00:00 2001 From: Alexander Chneerov Date: Sat, 8 Aug 2026 22:02:51 -0400 Subject: [PATCH 01/11] feature: added the necessary changes --- app/(authenticated)/webinars/actions.ts | 181 ++++++++++++++++++++++++ app/(authenticated)/webinars/schema.ts | 51 +++++++ 2 files changed, 232 insertions(+) create mode 100644 app/(authenticated)/webinars/actions.ts create mode 100644 app/(authenticated)/webinars/schema.ts diff --git a/app/(authenticated)/webinars/actions.ts b/app/(authenticated)/webinars/actions.ts new file mode 100644 index 0000000..5dc12d7 --- /dev/null +++ b/app/(authenticated)/webinars/actions.ts @@ -0,0 +1,181 @@ +"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: formData.has("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, + }); + } 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 [existing] = await db + .select({ id: webinars.id }) + .from(webinars) + .where(eq(webinars.id, parsed.data.webinar_id)) + .limit(1); + + if (!existing) { + return { errors: { _form: ["Webinar not found"] } }; + } + + 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; + } + + try { + await db + .update(webinars) + .set(patch) + .where(eq(webinars.id, parsed.data.webinar_id)); + } catch (error) { + console.error(error); + return { errors: { _form: ["Could not update webinar"] } }; + } + + 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 }; + } + + const deleted = await db + .delete(webinars) + .where(eq(webinars.id, parsed.data.webinar_id)) + .returning({ id: webinars.id }); + + 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..cb0e303 --- /dev/null +++ b/app/(authenticated)/webinars/schema.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; + +export const webinarTierSchema = z.enum(["free", "premium"]); + +const youtubeUrlSchema = z + .string() + .url("Enter a valid YouTube URL") + .refine( + (value) => { + const hostname = new URL(value).hostname.toLowerCase(); + return ["youtube.com", "www.youtube.com", "youtu.be", "www.youtu.be"].includes( + hostname, + ); + }, + { 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.boolean().default(true), +}); + +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.boolean().optional(), +}); + +export const deleteWebinarSchema = z.object({ + webinar_id: z.string().uuid("Invalid webinar"), +}); + +export function parseBooleanField(value: FormDataEntryValue | null): boolean { + if (value === null) return false; + if (typeof value !== "string") return true; + return ["1", "true", "on", "yes"].includes(value.toLowerCase()); +} From 276186f90491c26c54c0609d620970bc895daad0 Mon Sep 17 00:00:00 2001 From: Alexander Chneerov Date: Sat, 8 Aug 2026 22:03:03 -0400 Subject: [PATCH 02/11] small fix --- app/(authenticated)/webinars/schema.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/app/(authenticated)/webinars/schema.ts b/app/(authenticated)/webinars/schema.ts index cb0e303..7bf75e6 100644 --- a/app/(authenticated)/webinars/schema.ts +++ b/app/(authenticated)/webinars/schema.ts @@ -7,10 +7,17 @@ const youtubeUrlSchema = z .url("Enter a valid YouTube URL") .refine( (value) => { - const hostname = new URL(value).hostname.toLowerCase(); - return ["youtube.com", "www.youtube.com", "youtu.be", "www.youtu.be"].includes( - hostname, - ); + 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" }, ); From 2330015c4af155583b5e99f4e12d0195b270db8e Mon Sep 17 00:00:00 2001 From: achneerov Date: Sat, 15 Aug 2026 20:14:23 -0400 Subject: [PATCH 03/11] feat(webinars): remove unused parseBooleanField function --- app/(authenticated)/webinars/schema.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/(authenticated)/webinars/schema.ts b/app/(authenticated)/webinars/schema.ts index 7bf75e6..a79b58c 100644 --- a/app/(authenticated)/webinars/schema.ts +++ b/app/(authenticated)/webinars/schema.ts @@ -50,9 +50,3 @@ export const updateWebinarSchema = z.object({ export const deleteWebinarSchema = z.object({ webinar_id: z.string().uuid("Invalid webinar"), }); - -export function parseBooleanField(value: FormDataEntryValue | null): boolean { - if (value === null) return false; - if (typeof value !== "string") return true; - return ["1", "true", "on", "yes"].includes(value.toLowerCase()); -} From c6fe492484a003d0066bc020768e57de0f86b3d0 Mon Sep 17 00:00:00 2001 From: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:42:03 -0400 Subject: [PATCH 04/11] Update app/(authenticated)/webinars/schema.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit alright Co-authored-by: ϻartin <51332188+martin0024@users.noreply.github.com> Signed-off-by: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> --- app/(authenticated)/webinars/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(authenticated)/webinars/schema.ts b/app/(authenticated)/webinars/schema.ts index a79b58c..eacbfd8 100644 --- a/app/(authenticated)/webinars/schema.ts +++ b/app/(authenticated)/webinars/schema.ts @@ -44,7 +44,7 @@ export const updateWebinarSchema = z.object({ tier: webinarTierSchema.optional(), duration_minutes: durationSchema.optional(), youtube_url: z.union([youtubeUrlSchema, z.literal("")]).optional(), - is_active: z.boolean().optional(), + is_active: z.enum(["true", "false"]).optional(), }); export const deleteWebinarSchema = z.object({ From 4b9708b74aa958e24b13b90a03e8593e13beb1e5 Mon Sep 17 00:00:00 2001 From: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:49:59 -0400 Subject: [PATCH 05/11] Update app/(authenticated)/webinars/actions.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ϻartin <51332188+martin0024@users.noreply.github.com> Signed-off-by: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> --- app/(authenticated)/webinars/actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(authenticated)/webinars/actions.ts b/app/(authenticated)/webinars/actions.ts index 5dc12d7..322d64c 100644 --- a/app/(authenticated)/webinars/actions.ts +++ b/app/(authenticated)/webinars/actions.ts @@ -48,7 +48,7 @@ export async function createWebinar( tier: field(formData, "tier"), duration_minutes: field(formData, "duration_minutes"), youtube_url: field(formData, "youtube_url") || undefined, - is_active: formData.has("is_active") + is_active: field(formData, "is_active"), ? parseBooleanField(formData.get("is_active")) : true, }); From 3abefab9d98463e11e7fed61bb44c6a3f0b9ee56 Mon Sep 17 00:00:00 2001 From: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:50:36 -0400 Subject: [PATCH 06/11] Update app/(authenticated)/webinars/schema.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ϻartin <51332188+martin0024@users.noreply.github.com> Signed-off-by: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> --- app/(authenticated)/webinars/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(authenticated)/webinars/schema.ts b/app/(authenticated)/webinars/schema.ts index eacbfd8..fc6deb3 100644 --- a/app/(authenticated)/webinars/schema.ts +++ b/app/(authenticated)/webinars/schema.ts @@ -34,7 +34,7 @@ export const createWebinarSchema = z.object({ tier: webinarTierSchema, duration_minutes: durationSchema, youtube_url: youtubeUrlSchema.optional(), - is_active: z.boolean().default(true), + is_active: z.enum(["true", "false"]).optional(), }); export const updateWebinarSchema = z.object({ From f6670c2d2478643e83a7cc275f25afd9f525bf14 Mon Sep 17 00:00:00 2001 From: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:51:50 -0400 Subject: [PATCH 07/11] Update app/(authenticated)/webinars/actions.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ϻartin <51332188+martin0024@users.noreply.github.com> Signed-off-by: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> --- app/(authenticated)/webinars/actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(authenticated)/webinars/actions.ts b/app/(authenticated)/webinars/actions.ts index 322d64c..03dea21 100644 --- a/app/(authenticated)/webinars/actions.ts +++ b/app/(authenticated)/webinars/actions.ts @@ -64,7 +64,7 @@ export async function createWebinar( tier: parsed.data.tier, durationMinutes: parsed.data.duration_minutes, youtubeUrl: parsed.data.youtube_url || null, - isActive: parsed.data.is_active, + isActive: parsed.data.is_active !== "false", }); } catch (error) { console.error(error); From 11a96d33d81a85a568fcd19c459b23cf1eb21402 Mon Sep 17 00:00:00 2001 From: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:52:05 -0400 Subject: [PATCH 08/11] Update app/(authenticated)/webinars/actions.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ϻartin <51332188+martin0024@users.noreply.github.com> Signed-off-by: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> --- app/(authenticated)/webinars/actions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/(authenticated)/webinars/actions.ts b/app/(authenticated)/webinars/actions.ts index 03dea21..2001294 100644 --- a/app/(authenticated)/webinars/actions.ts +++ b/app/(authenticated)/webinars/actions.ts @@ -132,7 +132,7 @@ export async function updateWebinar( patch.youtubeUrl = parsed.data.youtube_url || null; } if (parsed.data.is_active !== undefined) { - patch.isActive = parsed.data.is_active; + patch.isActive = parsed.data.is_active === "true"; } try { From 439e6860cc306bc57be62a3e390b3a8334a35867 Mon Sep 17 00:00:00 2001 From: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:53:51 -0400 Subject: [PATCH 09/11] Update app/(authenticated)/webinars/actions.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ϻartin <51332188+martin0024@users.noreply.github.com> Signed-off-by: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> --- app/(authenticated)/webinars/actions.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/(authenticated)/webinars/actions.ts b/app/(authenticated)/webinars/actions.ts index 2001294..ea45207 100644 --- a/app/(authenticated)/webinars/actions.ts +++ b/app/(authenticated)/webinars/actions.ts @@ -167,10 +167,16 @@ export async function deleteWebinar( return { errors: parsed.error.flatten().fieldErrors }; } - const deleted = await db - .delete(webinars) - .where(eq(webinars.id, parsed.data.webinar_id)) - .returning({ id: webinars.id }); + 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"] } }; From 44fde7be696fd97395612c690dfa1af7072e76a3 Mon Sep 17 00:00:00 2001 From: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:57:24 -0400 Subject: [PATCH 10/11] Update app/(authenticated)/webinars/actions.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ϻartin <51332188+martin0024@users.noreply.github.com> Signed-off-by: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> --- app/(authenticated)/webinars/actions.ts | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/app/(authenticated)/webinars/actions.ts b/app/(authenticated)/webinars/actions.ts index ea45207..265e5ea 100644 --- a/app/(authenticated)/webinars/actions.ts +++ b/app/(authenticated)/webinars/actions.ts @@ -135,15 +135,13 @@ export async function updateWebinar( patch.isActive = parsed.data.is_active === "true"; } - try { - await db - .update(webinars) - .set(patch) - .where(eq(webinars.id, parsed.data.webinar_id)); - } catch (error) { - console.error(error); - return { errors: { _form: ["Could not update webinar"] } }; - } + 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." }; From 396325fff23fb75613e754865b82e4a939513b52 Mon Sep 17 00:00:00 2001 From: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:58:28 -0400 Subject: [PATCH 11/11] Update app/(authenticated)/webinars/actions.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: ϻartin <51332188+martin0024@users.noreply.github.com> Signed-off-by: Alexander Chneerov <123044733+achneerov@users.noreply.github.com> --- app/(authenticated)/webinars/actions.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/app/(authenticated)/webinars/actions.ts b/app/(authenticated)/webinars/actions.ts index 265e5ea..5800eb6 100644 --- a/app/(authenticated)/webinars/actions.ts +++ b/app/(authenticated)/webinars/actions.ts @@ -107,15 +107,7 @@ export async function updateWebinar( return { errors: parsed.error.flatten().fieldErrors }; } - const [existing] = await db - .select({ id: webinars.id }) - .from(webinars) - .where(eq(webinars.id, parsed.data.webinar_id)) - .limit(1); - - if (!existing) { - return { errors: { _form: ["Webinar not found"] } }; - } + const patch: Partial = { updatedAt: new Date(),