Skip to content
177 changes: 177 additions & 0 deletions app/(authenticated)/webinars/actions.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;
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<WebinarActionState> {
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<WebinarActionState> {
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<typeof webinars.$inferInsert> = {
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<WebinarActionState> {
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." };
}
52 changes: 52 additions & 0 deletions app/(authenticated)/webinars/schema.ts
Original file line number Diff line number Diff line change
@@ -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" },
);
Comment on lines +5 to +23

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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 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" },
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dude I tried that before and it lowkey crashes because if const hostname = new URL(value).hostname.toLowerCase(); fails then this crashes:
return [
"youtube.com",
"www.youtube.com",
"youtu.be",
"www.youtu.be",
].includes(hostname);


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"),
});
Loading