♻️ Refactor safe action middleware (#1809)

This commit is contained in:
Luke Vella 2025-07-11 11:29:36 +01:00 committed by GitHub
parent 6b4e5b3540
commit c2701a4d4f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 89 additions and 78 deletions

View file

@ -5,8 +5,9 @@ import { signOut } from "@/next-auth";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
export const deleteCurrentUserAction = authActionClient.action( export const deleteCurrentUserAction = authActionClient
async ({ ctx }) => { .metadata({ actionName: "delete_current_user" })
.action(async ({ ctx }) => {
const userId = ctx.user.id; const userId = ctx.user.id;
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
@ -34,18 +35,9 @@ export const deleteCurrentUserAction = authActionClient.action(
}, },
}); });
ctx.posthog?.capture({
event: "delete_account",
distinctId: ctx.user.id,
properties: {
email: ctx.user.email,
},
});
await signOut(); await signOut();
return { return {
success: true, success: true,
}; };
}, });
);

View file

@ -5,22 +5,24 @@ import { subject } from "@casl/ability";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export const makeMeAdminAction = authActionClient.action(async ({ ctx }) => { export const makeMeAdminAction = authActionClient
if (ctx.ability.cannot("update", subject("User", ctx.user), "role")) { .metadata({ actionName: "make_admin" })
throw new ActionError({ .action(async ({ ctx }) => {
code: "UNAUTHORIZED", if (ctx.ability.cannot("update", subject("User", ctx.user), "role")) {
message: "You are not authorized to update your role", throw new ActionError({
code: "UNAUTHORIZED",
message: "You are not authorized to update your role",
});
}
await prisma.user.update({
where: {
id: ctx.user.id,
},
data: {
role: "admin",
},
}); });
}
await prisma.user.update({ redirect("/control-panel");
where: {
id: ctx.user.id,
},
data: {
role: "admin",
},
}); });
redirect("/control-panel");
});

View file

@ -2,7 +2,9 @@
import { requireUserAbility } from "@/auth/queries"; import { requireUserAbility } from "@/auth/queries";
import { posthog } from "@rallly/posthog/server"; import { posthog } from "@rallly/posthog/server";
import { waitUntil } from "@vercel/functions"; import { waitUntil } from "@vercel/functions";
import { createSafeActionClient } from "next-safe-action"; import { createMiddleware, createSafeActionClient } from "next-safe-action";
import { revalidatePath } from "next/cache";
import z from "zod";
type ActionErrorCode = type ActionErrorCode =
| "UNAUTHORIZED" | "UNAUTHORIZED"
@ -28,7 +30,45 @@ export class ActionError extends Error {
} }
} }
const autoRevalidateMiddleware = createMiddleware().define(async ({ next }) => {
const result = await next();
revalidatePath("/", "layout");
return result;
});
const posthogMiddleware = createMiddleware<{
ctx: { user: { id: string } };
metadata: { actionName: string };
}>().define(async ({ ctx, next, metadata }) => {
let properties: Record<string, unknown> | undefined;
const result = await next({
ctx: {
posthog,
captureProperties: (props?: Record<string, unknown>) => {
properties = props;
},
},
});
posthog?.capture({
distinctId: ctx.user.id,
event: metadata.actionName,
properties,
});
if (posthog) {
waitUntil(posthog.shutdown());
}
return result;
});
export const actionClient = createSafeActionClient({ export const actionClient = createSafeActionClient({
defineMetadataSchema: () =>
z.object({
actionName: z.string(),
}),
handleServerError: async (error) => { handleServerError: async (error) => {
if (error instanceof ActionError) { if (error instanceof ActionError) {
return error.code; return error.code;
@ -36,22 +76,14 @@ export const actionClient = createSafeActionClient({
return "INTERNAL_SERVER_ERROR"; return "INTERNAL_SERVER_ERROR";
}, },
}).use(({ next }) => { }).use(autoRevalidateMiddleware);
const result = next({
ctx: {
posthog,
},
});
waitUntil(Promise.all([posthog?.shutdown()])); export const authActionClient = actionClient
.use(async ({ next }) => {
const { user, ability } = await requireUserAbility();
return result; return next({
}); ctx: { user, ability },
});
export const authActionClient = actionClient.use(async ({ next }) => { })
const { user, ability } = await requireUserAbility(); .use(posthogMiddleware);
return next({
ctx: { user, ability },
});
});

View file

@ -3,11 +3,11 @@ import { ActionError, authActionClient } from "@/features/safe-action/server";
import { getEmailClient } from "@/utils/emails"; import { getEmailClient } from "@/utils/emails";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { formatEventDateTime } from "./utils"; import { formatEventDateTime } from "./utils";
export const cancelEventAction = authActionClient export const cancelEventAction = authActionClient
.metadata({ actionName: "cancel_event" })
.inputSchema( .inputSchema(
z.object({ z.object({
eventId: z.string(), eventId: z.string(),
@ -43,14 +43,8 @@ export const cancelEventAction = authActionClient
}, },
}); });
revalidatePath("/", "layout"); ctx.captureProperties({
eventId: parsedInput.eventId,
ctx.posthog?.capture({
event: "cancel_event",
distinctId: ctx.user.id,
properties: {
event_id: parsedInput.eventId,
},
}); });
// notify attendees // notify attendees

View file

@ -1,12 +1,12 @@
"use server"; "use server";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { authActionClient } from "@/features/safe-action/server"; import { authActionClient } from "@/features/safe-action/server";
import { setupSchema } from "./schema"; import { setupSchema } from "./schema";
export const updateUserAction = authActionClient export const completeSetupAction = authActionClient
.metadata({ actionName: "complete_setup" })
.inputSchema(setupSchema) .inputSchema(setupSchema)
.action(async ({ parsedInput, ctx }) => { .action(async ({ parsedInput, ctx }) => {
const { name, timeZone, locale } = parsedInput; const { name, timeZone, locale } = parsedInput;
@ -20,19 +20,13 @@ export const updateUserAction = authActionClient
}, },
}); });
ctx.posthog?.capture({ ctx.captureProperties({
event: "user_setup_completed", $set: {
distinctId: ctx.user.id, name,
properties: { timeZone,
$set: { locale,
name,
timeZone,
locale,
},
}, },
}); });
revalidatePath("/", "layout");
redirect("/"); redirect("/");
}); });

View file

@ -20,7 +20,7 @@ import { useSafeAction } from "@/features/safe-action/client";
import { useTimezone } from "@/features/timezone"; import { useTimezone } from "@/features/timezone";
import { useTranslation } from "@/i18n/client"; import { useTranslation } from "@/i18n/client";
import { updateUserAction } from "../actions"; import { completeSetupAction } from "../actions";
import { type SetupFormValues, setupSchema } from "../schema"; import { type SetupFormValues, setupSchema } from "../schema";
interface SetupFormProps { interface SetupFormProps {
@ -30,7 +30,7 @@ interface SetupFormProps {
export function SetupForm({ defaultValues }: SetupFormProps) { export function SetupForm({ defaultValues }: SetupFormProps) {
const { timezone } = useTimezone(); const { timezone } = useTimezone();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const userSetupAction = useSafeAction(updateUserAction); const completeSetup = useSafeAction(completeSetupAction);
const form = useForm<SetupFormValues>({ const form = useForm<SetupFormValues>({
resolver: zodResolver(setupSchema), resolver: zodResolver(setupSchema),
@ -45,7 +45,7 @@ export function SetupForm({ defaultValues }: SetupFormProps) {
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(async (data) => { onSubmit={form.handleSubmit(async (data) => {
await userSetupAction.executeAsync(data); await completeSetup.executeAsync(data);
})} })}
> >
<div className="space-y-4"> <div className="space-y-4">
@ -108,8 +108,8 @@ export function SetupForm({ defaultValues }: SetupFormProps) {
</FormItem> </FormItem>
)} )}
/> />
{userSetupAction.result.serverError && ( {completeSetup.result.serverError && (
<FormMessage>{userSetupAction.result.serverError}</FormMessage> <FormMessage>{completeSetup.result.serverError}</FormMessage>
)} )}
</div> </div>
<div className="mt-6"> <div className="mt-6">

View file

@ -2,11 +2,11 @@
import { ActionError, authActionClient } from "@/features/safe-action/server"; import { ActionError, authActionClient } from "@/features/safe-action/server";
import { subject } from "@casl/ability"; import { subject } from "@casl/ability";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { getUser } from "./queries"; import { getUser } from "./queries";
export const changeRoleAction = authActionClient export const changeRoleAction = authActionClient
.metadata({ actionName: "change_role" })
.inputSchema( .inputSchema(
z.object({ z.object({
userId: z.string(), userId: z.string(),
@ -47,11 +47,10 @@ export const changeRoleAction = authActionClient
role, role,
}, },
}); });
revalidatePath("/control-panel");
}); });
export const deleteUserAction = authActionClient export const deleteUserAction = authActionClient
.metadata({ actionName: "delete_user" })
.inputSchema( .inputSchema(
z.object({ z.object({
userId: z.string(), userId: z.string(),
@ -86,8 +85,6 @@ export const deleteUserAction = authActionClient
}, },
}); });
revalidatePath("/control-panel");
return { return {
success: true, success: true,
}; };