import 'server-only' import {z, type ZodType} from 'zod/v4' import type {Profile} from '@/models/users' import type { FormAction, FormActionResponse, ServerFunction, ServerFunctionWithoutParams, } from '@/models/serverFunctions' import {getSessionProfileFromCookieOrThrow} from '@/lib/sessionUtils' import {validateSchema} from '@/lib/validateSchema' import type {Role} from '@/generated/prisma/enums' import type {Logger} from 'pino' import {getLogger} from '@/lib/logger' import {convertFormData} from '@/lib/convertFormData' const emptySchema = z.object({}) type EmptySchema = ZodType type PublicContext = {data: z.infer; logger: Logger} type ProtectedContext = PublicContext & {profile: Profile} type Context = Auth extends true ? ProtectedContext : PublicContext type WrappedServerFn = ( context: Context, ) => Promise | void> interface ServerFunctionOptions { // The function which contains the logic for the given server function or actions serverFn: WrappedServerFn // The schema to validate the submitted data against, defaults to an empty schema. schema?: Schema // Whether the function should be protected and require an authenticated user, defaults to true. authenticated?: Auth // A message to display in case of a server error. globalErrorMessage?: string // If set, the function will return the specified keys of the submitted data in case of success (useful if you want // to stay on the same page and update the form with the new data). // If set to true, all keys of the submitted data will be returned, if set to an array, only the specified keys // will be returned. sendBackOnSuccess?: (keyof z.infer)[] | true // The roles by which the server function can be executed, if no argument was passed, anyone can execute the function. requiredRoles?: Role[] // The name of the server function, used in logs. functionName: string } /** * A utility function used to abstract the common logic of form actions which are only accessible by authenticated * users. * * @param options An object containing the configuration for the form action. */ export function protectedFormAction( options: Omit, 'authenticated'>, ): FormAction { return formAction({...options, authenticated: true}) } /** * A utility function used to abstract the common logic of form actions which are accessible by all users (including * unauthenticated ones). * * @param options An object containing the configuration for the form action. */ export function publicFormAction( options: Omit, 'authenticated' | 'requiredRoles'>, ): FormAction { return formAction({...options, authenticated: false}) } /** * A utility function used to abstract the common logic of form actions. * * @param options An object containing the configuration for the form action. */ function formAction( options: ServerFunctionOptions, ): FormAction { return async ( _prevState: FormActionResponse, unvalidatedData: FormData, ): Promise> => { return handleServerFunction({ ...options, unvalidatedData, }) } } /** * A utility function used to abstract the common logic of server functions which are only accessible by authenticated * users. * * @param options An object containing the configuration for the server function. */ export function protectedServerFunction( options: Omit, 'authenticated' | 'schema'> & {schema?: undefined}, ): ServerFunctionWithoutParams export function protectedServerFunction( options: Omit, 'authenticated'>, ): ServerFunction export function protectedServerFunction( options: Omit, 'authenticated'>, ): ServerFunction | ServerFunctionWithoutParams { return serverFunction({...options, authenticated: true}) } /** * A utility function used to abstract the common logic of server functions which are accessible by all users (including * unauthenticated ones). * * @param options An object containing the configuration for the server function. */ export function publicServerFunction( options: Omit, 'authenticated' | 'requiredRoles' | 'schema'> & { schema?: undefined }, ): ServerFunctionWithoutParams export function publicServerFunction( options: Omit, 'authenticated' | 'requiredRoles'>, ): ServerFunction export function publicServerFunction( options: Omit, 'authenticated' | 'requiredRoles'>, ): ServerFunction | ServerFunctionWithoutParams { return serverFunction({...options, authenticated: false}) } /** * A utility function used to abstract the common logic of server functions. * * @param options An object containing the configuration for the server function. */ function serverFunction( options: ServerFunctionOptions, ): ServerFunction | ServerFunctionWithoutParams { return async (unvalidatedData?: z.infer): Promise => { await handleServerFunction({ ...options, unvalidatedData: unvalidatedData ?? {}, }) } } async function handleServerFunction( options: ServerFunctionOptions & {unvalidatedData: unknown}, ): Promise> { const start = Date.now() const authenticated = options?.authenticated === undefined ? true : options?.authenticated const schema = options?.schema ?? emptySchema const unvalidatedData = options.unvalidatedData const logger = await getLogger() const functionName = options.functionName ?? 'Server function' logger.info(`${functionName} called`) try { logger.trace(`Checking authentication for ${functionName}.`) const profile = authenticated ? await getSessionProfileFromCookieOrThrow() : undefined logger.trace(`Checking authorization for ${functionName}.`) if (authenticated && options.requiredRoles && !options.requiredRoles.includes(profile!.role)) { logger.warn(`Unauthorized user ${profile?.id} tried executing ${functionName ?? 'a server function'}.`) return { success: false, } } logger.trace(`Validating submitted data for ${functionName}.`) const {data, errors} = validateSchema(schema, unvalidatedData) // Generate the data to send back to the client in case of errors, or in case of success if sendBackOnSuccess is set. const submittedData = generateSubmittedData(unvalidatedData) if (errors) { logger.trace(`Validation of submitted data failed for ${functionName}.`) return { errors, success: false, submittedData, } } // Generate the return value for submittedData if required and filter out any unwanted keys. if (options.sendBackOnSuccess && Array.isArray(options.sendBackOnSuccess)) { const keysToSendBack = new Set(options.sendBackOnSuccess) Object.keys(submittedData).forEach(key => { if (!keysToSendBack.has(key as keyof z.infer)) { delete submittedData[key] } }) } // Await is required here, if we return fn directly, any thrown errors are not caught and returned through the // promise's catch method. const result = await options.serverFn({data, profile, logger} as Context) logger.info(`${functionName} completed successfully in ${Date.now() - start} ms`) return result ?? {success: true, submittedData: options.sendBackOnSuccess && submittedData} } catch (e) { const error = e as Error // The Next redirect function works by throwing an error, so we should not catch this error, but throw it again so // that Next can properly redirect the user. if (error.message === 'NEXT_REDIRECT') { logger.info(`${functionName} completed successfully in ${Date.now() - start} ms`) throw e } logger.error({ msg: `An error occurred in ${functionName}.`, error: error.message, }) } return { errors: { errors: [options.globalErrorMessage ?? 'Something went wrong, please ensure you are logged in and try again'], }, success: false, submittedData: generateSubmittedData(unvalidatedData), } } function generateSubmittedData(data: unknown): Record { if (!(data instanceof FormData)) { return data as Record } // This includes duplicate keys, such as a checkbox group merged into an array and multipart keys such as // property.0.property2 merged into {property: [{property2: value}]}. const formDataObj: Record = convertFormData(data) // Multipart keys must be sent back as property.0.property2 to be properly re-processed by react-hook-form. Object.keys(Object.fromEntries(data.entries())) .filter(k => k.includes('.')) .forEach(k => { // Add the key to the returned object. formDataObj[k] = data.get(k) as string // Remove the processed multipart key from the object to avoid duplication. const firstSegment = k.split('.')[0] delete formDataObj[firstSegment] }) return formDataObj }