import type { Access, BaseFilter, ClientUser, PayloadRequest, Where } from 'payload' /** * Assignment fields on the Users collection: each holds the ids of the * documents a Gläubiger (role 'user') is allowed to edit. */ export type AssignmentField = 'groups' | 'parishes' | 'pages' /** * Normalize a user's assignment field to an array of id strings. * The fields use maxDepth: 0 so values are plain ids, but handle * populated objects defensively. */ export const getAssignedIds = (user: unknown, field: AssignmentField): string[] => { const value = (user as { [K in AssignmentField]?: unknown } | null | undefined)?.[field] return toIdArray(value) } /** Normalize any relationship value (single or hasMany, id or populated doc) to id strings. */ export const toIdArray = (value: unknown): string[] => { if (value == null) return [] const arr = Array.isArray(value) ? value : [value] return arr.map((v) => typeof v === 'object' && v !== null ? String((v as { id: unknown }).id) : String(v), ) } const isStaff = (user: { roles?: unknown } | null | undefined): boolean => user?.roles === 'admin' || user?.roles === 'employee' /** * Update access: admin/employee always; role 'user' only for documents * they are assigned to. The collection-level probe (no id) is granted * only if the user has at least one assignment. */ export const isStaffOrAssigned = (field: AssignmentField): Access => ({ req: { user }, id }) => { if (!user) return false if (user.roles === 'admin' || user.roles === 'employee') return true if (user.roles !== 'user') return false const ids = getAssignedIds(user, field) if (ids.length === 0) return false if (id === undefined) return true return ids.includes(String(id)) } /** * Admin base filter: staff see everything (null = unfiltered); role 'user' * sees only assigned documents. An empty id list matches nothing. */ export const filterToAssigned = (field: AssignmentField): BaseFilter => ({ req: { user } }) => { if (!user || user.roles !== 'user') return null return { id: { in: getAssignedIds(user, field) } } } /** * admin.hidden: hide the collection from role 'user' unless they have at * least one assignment; never hidden for staff. */ export const hideUnlessAssigned = (field: AssignmentField) => ({ user }: { user: ClientUser }): boolean => { if (!user || user.roles !== 'user') return false return getAssignedIds(user, field).length === 0 } /** * admin.hidden: hide the collection from role 'user' unless they have at * least one assignment in ANY of the given fields. */ export const hideUnlessAssignedAny = (fields: AssignmentField[]) => ({ user }: { user: ClientUser }): boolean => { if (!user || user.roles !== 'user') return false return fields.every((field) => getAssignedIds(user, field).length === 0) } /** True if at least one of the given ids is in the user's assignment field. */ export const hasAssignedMatch = (user: unknown, ids: unknown, field: AssignmentField): boolean => { const assigned = getAssignedIds(user, field) return toIdArray(ids).some((id) => assigned.includes(id)) } /** * defaultValue for hasMany relationship fields: when a Gläubiger has exactly * one assignment, pre-fill it on new documents. Staff get no pre-fill. */ export const defaultToOnlyAssigned = (field: AssignmentField) => ({ user }: { user: unknown }): string[] | undefined => { if ((user as { roles?: unknown } | null)?.roles !== 'user') return undefined const ids = getAssignedIds(user, field) return ids.length === 1 ? ids : undefined } /** * Ids in `value` that are neither carried over from `previousValue` nor among * the user's own assignments — i.e. foreign connections the user is trying to * ADD. Existing foreign connections (set by staff) are not flagged. */ export const unassignedAdditions = ( user: unknown, field: AssignmentField, value: unknown, previousValue: unknown, ): string[] => { const assigned = getAssignedIds(user, field) const previous = toIdArray(previousValue) return toIdArray(value).filter((id) => !previous.includes(id) && !assigned.includes(id)) } // --------------------------------------------------------------------------- // Worship: a worship doc has no parish field — only `location` (church). // A Gläubiger's scope is the union of churches of their assigned parishes. // --------------------------------------------------------------------------- /** Resolve the user's assigned parishes to the union of their church ids. */ export const resolveAssignedChurchIds = async ( req: PayloadRequest, user: unknown, ): Promise => { const parishIds = getAssignedIds(user, 'parishes') if (parishIds.length === 0) return [] const { docs } = await req.payload.find({ collection: 'parish', where: { id: { in: parishIds } }, depth: 0, pagination: false, select: { churches: true }, req, }) return [...new Set(docs.flatMap((doc) => toIdArray((doc as { churches?: unknown }).churches)))] } /** * Worship create access. The admin UI probes create permission without data; * the actual create always carries data, where `location` is enforced (and * additionally validated on the field for a German error message). */ export const canCreateAssignedWorship = (): Access => async ({ req, data }) => { const user = req.user if (!user) return false if (isStaff(user)) return true if (user.roles !== 'user') return false const churchIds = await resolveAssignedChurchIds(req, user) if (churchIds.length === 0) return false if (data?.location === undefined) return true return churchIds.includes(String(data.location)) } /** Worship update/delete access: staff always; role 'user' only within their churches. */ export const canMutateAssignedWorship = (): Access => async ({ req }) => { const user = req.user if (!user) return false if (isStaff(user)) return true if (user.roles !== 'user') return false const churchIds = await resolveAssignedChurchIds(req, user) if (churchIds.length === 0) return false return { location: { in: churchIds } } as Where } /** Worship admin list filter: staff unfiltered; role 'user' only their churches. */ export const filterWorshipToAssigned = (): BaseFilter => async ({ req }) => { const user = req.user if (!user || user.roles !== 'user') return null return { location: { in: await resolveAssignedChurchIds(req, user) } } } // --------------------------------------------------------------------------- // Events: connected to groups AND/OR parishes; one own match grants access. // --------------------------------------------------------------------------- /** * Where matching events with at least one assigned group OR parish. * `prefix: 'event.'` targets the parent event from the occurrence collection. * Returns false when the user has no assignments at all. */ export const eventMatchWhere = (user: unknown, prefix = ''): false | Where => { const groupIds = getAssignedIds(user, 'groups') const parishIds = getAssignedIds(user, 'parishes') const or: Where[] = [] if (groupIds.length > 0) or.push({ [`${prefix}group`]: { in: groupIds } }) if (parishIds.length > 0) or.push({ [`${prefix}parish`]: { in: parishIds } }) if (or.length === 0) return false return { or } } /** * Event create access: staff always; role 'user' with at least one group or * parish assignment. Which connections the new event must carry is enforced * by the combined validate on the `group` field. */ export const canCreateAssignedEvent = (): Access => ({ req: { user } }) => { if (!user) return false if (isStaff(user)) return true if (user.roles !== 'user') return false return ( getAssignedIds(user, 'groups').length > 0 || getAssignedIds(user, 'parishes').length > 0 ) } /** Event update/delete access: staff always; role 'user' via one-match Where. */ export const canMutateAssignedEvent = (): Access => ({ req: { user } }) => { if (!user) return false if (isStaff(user)) return true if (user.roles !== 'user') return false return eventMatchWhere(user) } /** Events admin list filter: staff unfiltered; role 'user' one-match filter. */ export const filterEventsToAssigned = (): BaseFilter => ({ req: { user } }) => { if (!user || user.roles !== 'user') return null const where = eventMatchWhere(user) return where === false ? { id: { in: [] } } : where } /** Occurrence update access: whoever may edit the parent event may toggle its occurrences. */ export const canMutateOccurrenceOfAssignedEvent = (): Access => ({ req: { user } }) => { if (!user) return false if (isStaff(user)) return true if (user.roles !== 'user') return false return eventMatchWhere(user, 'event.') } // --------------------------------------------------------------------------- // Announcements: scoped by the `parish` field (hasMany), not by church. // --------------------------------------------------------------------------- /** * Announcement create access: staff always; role 'user' with at least one * parish assignment. Which parishes the new announcement may carry is * enforced by the validate on the `parish` field. */ export const canCreateAssignedAnnouncement = (): Access => ({ req: { user } }) => { if (!user) return false if (isStaff(user)) return true if (user.roles !== 'user') return false return getAssignedIds(user, 'parishes').length > 0 } /** Announcement update/delete access: staff always; role 'user' only their parishes. */ export const canMutateAssignedAnnouncement = (): Access => ({ req: { user } }) => { if (!user) return false if (isStaff(user)) return true if (user.roles !== 'user') return false const parishIds = getAssignedIds(user, 'parishes') if (parishIds.length === 0) return false return { parish: { in: parishIds } } as Where } /** Announcements admin list filter: staff unfiltered; role 'user' only their parishes. */ export const filterAnnouncementsToAssigned = (): BaseFilter => ({ req: { user } }) => { if (!user || user.roles !== 'user') return null return { parish: { in: getAssignedIds(user, 'parishes') } } }