openapi: 3.0.0 info: title: 'ChurchCRM Private API' description: 'Authenticated REST API for ChurchCRM. All endpoints require an API key passed via the x-api-key header. Obtain your API key from Profile → API Key inside the application, or via POST /public/user/login. Many endpoints require specific role permissions beyond basic authentication.' contact: name: ChurchCRM url: 'https://churchcrm.io' email: info@churchcrm.io license: name: MIT url: 'https://opensource.org/licenses/MIT' version: 7.8.0 servers: - url: '{scheme}://{host}/api' description: 'Self-hosted ChurchCRM API (/api)' variables: scheme: enum: - https - http default: https description: 'Protocol (https for production, http for local)' host: default: your-server.com description: 'Your ChurchCRM server hostname' - url: '{scheme}://{host}/admin' description: 'Self-hosted ChurchCRM Admin (/admin)' variables: scheme: enum: - https - http default: https description: 'Protocol (https for production, http for local)' host: default: your-server.com description: 'Your ChurchCRM server hostname' - url: '{scheme}://{host}' description: 'Self-hosted ChurchCRM root (for /kiosk and /plugins)' variables: scheme: enum: - https - http default: https description: 'Protocol (https for production, http for local)' host: default: your-server.com description: 'Your ChurchCRM server hostname' - url: 'http://localhost/churchcrm/api' description: 'Local development — API (/api)' - url: 'http://localhost/churchcrm/admin' description: 'Local development — Admin (/admin)' - url: 'http://localhost/churchcrm' description: 'Local development — root (for /kiosk and /plugins)' paths: '/search/{query}': get: tags: - Search summary: 'Search persons, families, groups, deposits, payments, and calendar events' parameters: - name: query in: path description: 'Search term to match against all supported entity types' required: true schema: type: string responses: '200': description: 'Array of search result provider groups, each containing a type label and matched results' content: application/json: schema: type: array items: properties: { results: { type: array, items: { type: object } } } type: object security: - ApiKeyAuth: [] /user/current/refresh2fasecret: post: tags: - 2FA summary: 'Begin 2FA enrollment — provision a new TOTP secret and return a QR code data URI' responses: '200': description: 'QR code data URI for TOTP enrollment' content: application/json: schema: properties: TwoFAQRCodeDataUri: { type: string } type: object security: - ApiKeyAuth: [] /user/current/refresh2farecoverycodes: post: tags: - 2FA summary: 'Generate new 2FA recovery codes for the current user' responses: '200': description: 'Array of new recovery codes' content: application/json: schema: properties: TwoFARecoveryCodes: { type: array, items: { type: string } } type: object security: - ApiKeyAuth: [] /user/current/remove2fasecret: post: tags: - 2FA summary: 'Remove the 2FA secret from the current user (disables 2FA)' responses: '200': description: '2FA secret removed' security: - ApiKeyAuth: [] /user/current/get2faqrcode: get: tags: - 2FA summary: "Get the current user's 2FA QR code as a PNG image" responses: '200': description: 'PNG image of the 2FA QR code' content: image/png: { } security: - ApiKeyAuth: [] /user/current/test2FAEnrollmentCode: post: tags: - 2FA summary: 'Validate a TOTP enrollment code to complete 2FA setup' requestBody: required: true content: application/json: schema: properties: enrollmentCode: type: string type: object responses: '200': description: 'Whether the enrollment code is valid' content: application/json: schema: properties: IsEnrollmentCodeValid: { type: boolean } type: object security: - ApiKeyAuth: [] /user/current/2fa-status: get: tags: - 2FA summary: 'Get the 2FA enabled status for the current user' responses: '200': description: '2FA enabled status' content: application/json: schema: properties: IsEnabled: { type: boolean } type: object security: - ApiKeyAuth: [] '/user/{userId}/apikey/regen': post: tags: - Users summary: 'Regenerate the API key for a user' parameters: - name: userId in: path required: true schema: type: integer responses: '200': description: 'New API key' content: application/json: schema: properties: apiKey: { type: string } type: object security: - ApiKeyAuth: [] '/user/{userId}/config/{key}': post: tags: - Users summary: 'Update a named config string for a user' parameters: - name: userId in: path required: true schema: type: integer - name: key in: path required: true schema: type: string requestBody: required: true content: application/json: schema: properties: value: type: string type: object responses: '200': description: 'Updated config key/value pair' security: - ApiKeyAuth: [] '/user/{userId}/setting/{settingName}': get: tags: - Users summary: 'Get a named setting value for a user' parameters: - name: userId in: path required: true schema: type: integer - name: settingName in: path required: true schema: type: string responses: '200': description: 'Setting value' content: application/json: schema: properties: value: { type: string } type: object security: - ApiKeyAuth: [] post: tags: - Users summary: 'Set a named setting value for a user' parameters: - name: userId in: path required: true schema: type: integer - name: settingName in: path required: true schema: type: string requestBody: required: true content: application/json: schema: properties: value: type: string type: object responses: '200': description: 'Updated setting value' content: application/json: schema: properties: value: { type: string } type: object security: - ApiKeyAuth: [] /events: get: tags: - Calendar summary: 'List all events' description: 'Returns all calendar events with their linked group associations.' operationId: getAllEvents responses: '200': description: 'List of events' content: application/json: schema: properties: Events: { type: array, items: { properties: { Id: { type: integer, example: 1 }, Title: { type: string, example: 'Sunday Service' }, Desc: { type: string, nullable: true }, Start: { type: string, format: date-time }, End: { type: string, format: date-time }, Groups: { type: array, items: { properties: { Id: { type: integer }, Name: { type: string } }, type: object } } }, type: object } } type: object '401': description: Unauthorized '404': description: 'No events found' security: - ApiKeyAuth: [] post: tags: - Calendar summary: 'Create a new event' operationId: newEvent requestBody: required: true content: application/json: schema: required: - Title - Type - Start - End - PinnedCalendars properties: Title: type: string example: 'Easter Service' Type: description: 'Event type ID from GET /events/types' type: integer example: 1 Desc: type: string nullable: true Start: type: string format: date-time example: '2026-04-05T09:00:00' End: type: string format: date-time example: '2026-04-05T11:00:00' Text: description: 'Rich text body (HTML allowed)' type: string nullable: true PinnedCalendars: description: "Without the global AddEvent right: only the event ministry's own calendar and the church calendars opened to that ministry (GET /calendars/pinnable)" type: array items: { type: integer } example: [1] MinistryId: description: 'Volunteer v2: owning volunteer ministry. Only an administrator, a global volunteer manager or a coordinator of that ministry may set it. A volunteer coordinator without the global AddEvent right MUST supply one they manage.' type: integer nullable: true LinkedGroupId: description: "Audience group (the event's non-attendee list is built from it). Without the global AddEvent right: only the event ministry's own pool group or a class linked to one of its teams. Omit the key to leave it unchanged; 0 clears it." type: integer nullable: true type: object responses: '200': description: 'Event created' content: application/json: schema: properties: success: { type: boolean, example: true } type: object '400': description: 'Invalid event type, calendar ID or volunteer ministry' '401': description: Unauthorized '403': description: 'AddEvents role required, or the caller may not assign the event to that volunteer ministry, pin it to one of those calendars or link that audience group' security: - ApiKeyAuth: [] /events/types: get: tags: - Calendar summary: 'List all event types' operationId: getEventTypes responses: '200': description: 'Ordered list of event types' content: application/json: schema: type: array items: properties: { Id: { type: integer, example: 1 }, Name: { type: string, example: 'Worship Service' } } type: object '401': description: Unauthorized '404': description: 'No event types found' security: - ApiKeyAuth: [] '/events/{id}': get: tags: - Calendar summary: 'Get an event by ID' operationId: getEvent parameters: - name: id in: path required: true schema: type: integer example: 1 responses: '200': description: 'Event object' content: application/json: schema: properties: Id: { type: integer } Title: { type: string } Start: { type: string, format: date-time } End: { type: string, format: date-time } MinistryId: { description: 'Owning volunteer ministry (Volunteer v2), or null', type: integer, nullable: true } MinistryName: { description: 'Name of the owning volunteer ministry, or null', type: string, nullable: true } type: object '401': description: Unauthorized '404': description: 'Event not found' security: - ApiKeyAuth: [] post: tags: - Calendar summary: 'Update an existing event' operationId: updateEvent parameters: - name: id in: path required: true schema: type: integer example: 1 requestBody: required: true content: application/json: schema: properties: Title: type: string Desc: type: string nullable: true Start: type: string format: date-time End: type: string format: date-time Text: type: string nullable: true PinnedCalendars: description: 'Replaces the pins. Omit the key to leave them unchanged. Without the global AddEvent right, only pins that change are checked, against the same calendars POST /events allows' type: array items: { type: integer } MinistryId: description: 'Volunteer v2: owning volunteer ministry. Omit the key to leave it unchanged; null or 0 clears it (global AddEvent right required to clear).' type: integer nullable: true LinkedGroupId: description: "Audience group (the event's non-attendee list is built from it). Without the global AddEvent right: only the event ministry's own pool group or a class linked to one of its teams. Omit the key to leave it unchanged; 0 clears it." type: integer nullable: true type: object responses: '200': description: 'Event updated' content: application/json: schema: properties: success: { type: boolean, example: true } type: object '400': description: 'Unknown volunteer ministry' '401': description: Unauthorized '403': description: "AddEvents role required, the event belongs to a volunteer ministry the caller does not manage, a pin it adds or removes is not the caller's to change, or an audience group it adds or removes is not the caller's to change" '404': description: 'Event not found' security: - ApiKeyAuth: [] delete: tags: - Calendar summary: 'Delete an event' operationId: deleteEvent parameters: - name: id in: path required: true schema: type: integer example: 1 responses: '200': description: 'Event deleted' content: application/json: schema: properties: success: { type: boolean, example: true } type: object '401': description: Unauthorized '403': description: 'AddEvents role required' '404': description: 'Event not found' security: - ApiKeyAuth: [] '/events/{id}/primarycontact': get: tags: - Calendar summary: "Get an event's primary contact person" operationId: getEventPrimaryContact parameters: - name: id in: path required: true schema: type: integer example: 1 responses: '200': description: 'Person object' '401': description: Unauthorized '404': description: 'Event or primary contact not found' security: - ApiKeyAuth: [] '/events/{id}/secondarycontact': get: tags: - Calendar summary: "Get an event's secondary contact person" operationId: getEventSecondaryContact parameters: - name: id in: path required: true schema: type: integer example: 1 responses: '200': description: 'Person object' '401': description: Unauthorized '404': description: 'Event or secondary contact not found' security: - ApiKeyAuth: [] '/events/{id}/location': get: tags: - Calendar summary: "Get an event's location" operationId: getEventLocation parameters: - name: id in: path required: true schema: type: integer example: 1 responses: '200': description: 'Location object' '401': description: Unauthorized '404': description: 'Event or location not found' security: - ApiKeyAuth: [] '/events/{id}/audience': get: tags: - Calendar summary: "Get an event's audience (linked groups)" operationId: getEventAudience parameters: - name: id in: path required: true schema: type: integer example: 1 responses: '200': description: 'Array of audience/group objects' '401': description: Unauthorized '404': description: 'Event or audience not found' security: - ApiKeyAuth: [] /events/repeat: post: tags: - Calendar summary: 'Create a series of repeat events' description: 'Generates individual event records for each occurrence of a recurring event within a date range. Each created event is independently editable. Shares one recurrence engine with POST /events/generate-recurring: the same recurrence and date range produce the same occurrence dates on both endpoints, and a single call creates at most 366 events.' operationId: createRepeatEvents requestBody: required: true content: application/json: schema: required: - Title - Type - StartTime - EndTime - RecurType - RangeStart - RangeEnd properties: Title: type: string example: 'Sunday Worship Service' Type: description: 'Event type ID from GET /events/types' type: integer example: 1 Desc: type: string nullable: true Text: description: 'Rich text body (HTML allowed)' type: string nullable: true StartTime: description: 'Event start time in HH:MM format' type: string example: '09:00' EndTime: description: 'Event end time in HH:MM format' type: string example: '10:30' RecurType: type: string example: weekly enum: [weekly, monthly, yearly] RecurDOW: description: 'Day of week for weekly recurrence (e.g. Sunday)' type: string example: Sunday RecurDOM: description: 'Day of month (1-31) for monthly recurrence' type: integer example: 1 RecurDOY: description: 'Month-day in MM-DD format for yearly recurrence' type: string example: 04-12 RangeStart: description: 'First date of the repetition range (inclusive)' type: string format: date example: '2026-01-01' RangeEnd: description: 'Last date of the repetition range (inclusive)' type: string format: date example: '2026-12-31' PinnedCalendars: type: array items: { type: integer } nullable: true LinkedGroupId: type: integer nullable: true Inactive: type: integer nullable: true enum: [0, 1] SkipExisting: description: 'Skip occurrence dates that already have an active event of this type, making the call idempotent. Same semantics as POST /events/generate-recurring.' type: boolean default: false type: object responses: '200': description: 'Repeat events created' content: application/json: schema: properties: success: { type: boolean, example: true } count: { type: integer, example: 52 } skipped: { description: 'Occurrences skipped because an event of this type already existed on that date (only non-zero when SkipExisting is true)', type: integer, example: 0 } eventIds: { type: array, items: { type: integer } } type: object '400': description: 'Invalid input (bad event type, invalid dates, unknown recurrence type, or more than 366 occurrences)' '401': description: Unauthorized '403': description: 'AddEvents role required' security: - ApiKeyAuth: [] '/events/{id}/time': post: tags: - Calendar summary: "Update an event's start and end times" operationId: setEventTime parameters: - name: id in: path required: true schema: type: integer example: 1 requestBody: required: true content: application/json: schema: required: - startTime - endTime properties: startTime: type: string format: date-time example: '2026-04-05T09:00:00' endTime: type: string format: date-time example: '2026-04-05T11:00:00' type: object responses: '200': description: 'Time updated' content: application/json: schema: properties: success: { type: boolean, example: true } type: object '401': description: Unauthorized '403': description: 'AddEvents role required' '404': description: 'Event not found' security: - ApiKeyAuth: [] '/events/{id}/status': post: tags: - Calendar summary: 'Activate or deactivate an event' operationId: setEventStatus parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - active properties: active: description: 'true to activate, false to deactivate' type: boolean example: true type: object responses: '200': description: 'Event status updated' '401': description: Unauthorized '403': description: 'AddEvents role required' '404': description: 'Event not found' security: - ApiKeyAuth: [] /events/quick-create: post: tags: - Calendar summary: 'Quick-create an event from EventType defaults' description: "Creates an event with minimal input, using EventType defaults for title, time, and group. **At least one of eventTypeId or groupId is required.** If only groupId is provided, the event type is auto-detected from the group's linked EventType. If an event of the same type already exists for the given date, returns the existing event instead." operationId: quickCreateEvent requestBody: required: true content: application/json: schema: properties: eventTypeId: description: 'Event type ID. Either eventTypeId OR groupId is required.' type: integer example: 5 groupId: description: "Group ID — when provided without eventTypeId, the event type is auto-detected from the group's linked EventType." type: integer example: 12 date: description: 'Event date (defaults to today)' type: string format: date example: '2026-04-05' type: object responses: '200': description: 'Event created or existing event found' content: application/json: schema: properties: eventId: { type: integer, example: 42 } created: { type: boolean, example: true } title: { type: string, example: 'Youth Sunday School — Apr 5, 2026' } type: object '400': description: 'Invalid event type ID, or a date that is not a valid YYYY-MM-DD' '401': description: Unauthorized '403': description: 'AddEvents role required' security: - ApiKeyAuth: [] /events/today: get: tags: - Calendar summary: "Get today's events with attendance stats" operationId: getTodaysEvents responses: '200': description: "Today's events" content: application/json: schema: properties: events: { type: array, items: { properties: { id: { type: integer }, title: { type: string }, typeName: { type: string }, start: { type: string, format: date-time }, end: { type: string, format: date-time }, checkedIn: { type: integer }, totalAttendees: { type: integer } }, type: object } } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] '/events/{id}/roster': get: tags: - Calendar summary: 'Get group members with attendance status for an event' description: 'Returns all members of groups linked to this event, along with their check-in/check-out status.' operationId: getEventRoster parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Roster with attendance status' '401': description: Unauthorized '404': description: 'Event not found' security: - ApiKeyAuth: [] '/events/{id}/checkin': post: tags: - Calendar summary: 'Check in a person to an event via AJAX' operationId: checkinPerson parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - personId properties: personId: type: integer example: 101 checkedInById: description: 'Person ID of whoever is checking this person in (e.g., parent dropping off child)' type: integer example: 55 nullable: true type: object responses: '200': description: 'Person checked in' '400': description: 'Invalid person ID' '401': description: Unauthorized '404': description: 'Event not found' security: - ApiKeyAuth: [] '/events/{id}/checkout': post: tags: - Calendar summary: 'Check out a person from an event via AJAX' operationId: checkoutPerson parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - personId properties: personId: type: integer example: 101 checkedOutById: description: 'Person ID of whoever is checking this person out (e.g., parent picking up child)' type: integer example: 55 nullable: true type: object responses: '200': description: 'Person checked out' '400': description: 'Invalid person ID' '401': description: Unauthorized '404': description: 'Event not found' security: - ApiKeyAuth: [] '/events/{id}/checkin-all': post: tags: - Calendar summary: 'Batch check-in all group members for an event' operationId: checkinAll parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'All members checked in' content: application/json: schema: properties: success: { type: boolean } checkedIn: { type: integer } type: object '401': description: Unauthorized '403': description: 'AddEvents role required' '404': description: 'Event not found' security: - ApiKeyAuth: [] '/events/{id}/checkout-all': post: tags: - Calendar summary: 'Batch check-out all checked-in people from an event' operationId: checkoutAll parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'All people checked out' content: application/json: schema: properties: success: { type: boolean } checkedOut: { type: integer } type: object '401': description: Unauthorized '403': description: 'AddEvents role required' '404': description: 'Event not found' security: - ApiKeyAuth: [] '/events/{id}/checkin-people': post: tags: - Calendar summary: 'Batch check-in a list of people for an event' description: "Used by Family View 'Check In Family' (#6838) and any other UI that needs to check in a known set of people. Each ID is processed via Event::checkInPerson() which creates timeline notes and timestamps." operationId: checkinPeople parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - personIds properties: personIds: type: array items: { type: integer } example: [101, 102, 103] checkedInById: description: 'Person ID of whoever is checking these people in' type: integer example: 55 nullable: true type: object responses: '200': description: 'People checked in' content: application/json: schema: properties: success: { type: boolean } checkedIn: { type: integer } type: object '400': description: 'Invalid input' '401': description: Unauthorized '403': description: 'AddEvents role required' '404': description: 'Event not found' security: - ApiKeyAuth: [] '/events/{id}/attendance/{personId}': delete: tags: - Calendar summary: "Delete a person's attendance record from an event" operationId: deleteAttendance parameters: - name: id in: path required: true schema: type: integer - name: personId in: path required: true schema: type: integer responses: '200': description: 'Attendance deleted' '404': description: 'Event or attendance record not found' '401': description: Unauthorized '403': description: 'AddEvents role required' security: - ApiKeyAuth: [] /events/generate-recurring: post: tags: - Calendar summary: "Generate recurring events from an EventType's recurrence settings" description: "Creates multiple events between startDate and endDate based on the EventType's recurrence pattern (weekly, monthly, or yearly). Optionally skips dates where an event of the same type already exists. Shares one recurrence engine with POST /events/repeat: the same recurrence and date range produce the same occurrence dates on both endpoints, and a single call creates at most 366 events." operationId: generateRecurringEvents requestBody: required: true content: application/json: schema: required: - eventTypeId - startDate - endDate properties: eventTypeId: type: integer example: 5 startDate: type: string format: date example: '2026-04-05' endDate: type: string format: date example: '2026-06-28' skipExisting: description: 'Skip dates that already have an event of this type' type: boolean example: true pinnedCalendars: description: 'Calendar IDs to pin generated events to' type: array items: { type: integer } example: [1] type: object responses: '200': description: 'Events generated' content: application/json: schema: properties: created: { type: integer, example: 12 } skipped: { type: integer, example: 1 } events: { type: array, items: { properties: { id: { type: integer }, title: { type: string }, date: { type: string, format: date } }, type: object } } type: object '400': description: 'Invalid input (bad event type, invalid dates, event type with no recurrence configured, or more than 366 occurrences)' '401': description: Unauthorized '403': description: 'AddEvents role required' security: - ApiKeyAuth: [] /events/audit/stuck: get: tags: - Calendar summary: 'List past events that are still marked active' description: 'Returns every event whose end date has passed but which is still marked Active. Each row includes the count of attendees still checked in (no checkout). This is the audit set — anything in this list represents an event that was never properly closed, regardless of whether anyone forgot to check out.' operationId: getStuckEvents responses: '200': description: 'List of stuck events with stats' security: - ApiKeyAuth: [] /events/audit/close: post: tags: - Calendar summary: 'Batch close stuck events' description: 'For each event id provided, checks out anyone still checked in and marks the event inactive. Idempotent — events that are already closed/empty are skipped.' operationId: closeStuckEvents requestBody: required: true content: application/json: schema: required: - eventIds properties: eventIds: type: array items: { type: integer } example: [42, 43, 44] checkoutPeople: description: 'Auto check-out anyone still checked in (defaults to true)' type: boolean example: true deactivate: description: 'Mark the event inactive after closing (defaults to true)' type: boolean example: true type: object responses: '200': description: 'Summary of close operation' security: - ApiKeyAuth: [] /systemcalendars: get: tags: - Calendar summary: 'List all system calendars' description: 'Returns built-in system calendars (birthdays, anniversaries, events).' operationId: getSystemCalendars responses: '200': description: 'Array of system calendar objects' '401': description: Unauthorized security: - ApiKeyAuth: [] '/systemcalendars/{id}/events': get: tags: - Calendar summary: 'Get events from a system calendar' operationId: getSystemCalendarEvents parameters: - name: id in: path description: 'System calendar ID' required: true schema: type: integer example: 1 - name: start in: query description: 'Filter start date (ISO 8601)' required: false schema: type: string format: date - name: end in: query description: 'Filter end date (ISO 8601)' required: false schema: type: string format: date responses: '200': description: 'Array of event objects' '401': description: Unauthorized '404': description: 'System calendar not found' security: - ApiKeyAuth: [] '/systemcalendars/{id}/events/{eventid}': get: tags: - Calendar summary: 'Get a single event from a system calendar' operationId: getSystemCalendarEventById parameters: - name: id in: path required: true schema: type: integer - name: eventid in: path required: true schema: type: integer responses: '200': description: 'Event object' '401': description: Unauthorized '404': description: 'Calendar or event not found' security: - ApiKeyAuth: [] '/systemcalendars/{id}/fullcalendar': get: tags: - Calendar summary: 'Get system calendar events in FullCalendar format' operationId: getSystemCalendarFullCalendarEvents parameters: - name: id in: path required: true schema: type: integer - name: start in: query required: false schema: type: string format: date - name: end in: query required: false schema: type: string format: date responses: '200': description: 'FullCalendar-compatible event array' '401': description: Unauthorized '404': description: 'Calendar or events not found' security: - ApiKeyAuth: [] /calendars: get: tags: - Calendar summary: 'List user-created calendars' description: 'Returns all user calendars. Optionally filter by ID using GET /calendars/{id}.' operationId: getUserCalendars responses: '200': description: 'Array of calendar objects' '401': description: Unauthorized '404': description: 'No calendars found' security: - ApiKeyAuth: [] post: tags: - Calendar summary: 'Create a new calendar' operationId: newCalendar requestBody: required: true content: application/json: schema: required: - Name - ForegroundColor - BackgroundColor properties: Name: type: string example: 'Youth Group' ForegroundColor: type: string example: '#ffffff' BackgroundColor: type: string example: '#3788d8' type: object responses: '200': description: 'New calendar object' '401': description: Unauthorized '403': description: 'AddEvents role required' security: - ApiKeyAuth: [] '/calendars/{id}': get: tags: - Calendar summary: 'Get a specific user calendar' operationId: getUserCalendarById parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Calendar object' '401': description: Unauthorized '404': description: 'Calendar not found' security: - ApiKeyAuth: [] delete: tags: - Calendar summary: 'Delete a user calendar' operationId: deleteUserCalendar parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Calendar deleted' content: application/json: schema: properties: success: { type: boolean, example: true } type: object '400': description: 'Missing or invalid calendar ID' '401': description: Unauthorized '403': description: 'AddEvents role required' '409': description: 'Calendar still has events assigned; remove them first' security: - ApiKeyAuth: [] '/calendars/{id}/fullcalendar': get: tags: - Calendar summary: 'Get user calendar events in FullCalendar format' operationId: getUserCalendarFullCalendarEvents parameters: - name: id in: path required: true schema: type: integer - name: start in: query required: true schema: type: string format: date - name: end in: query required: true schema: type: string format: date responses: '200': description: 'FullCalendar-compatible event array' '401': description: Unauthorized '404': description: 'Calendar or events not found' security: - ApiKeyAuth: [] '/calendars/{id}/events': get: tags: - Calendar summary: 'Get events for a user calendar' operationId: getUserCalendarEvents parameters: - name: id in: path required: true schema: type: integer - name: start in: query required: false schema: type: string format: date - name: end in: query required: false schema: type: string format: date responses: '200': description: 'Array of event objects' '401': description: Unauthorized '404': description: 'Calendar not found' security: - ApiKeyAuth: [] '/calendars/{id}/NewAccessToken': post: tags: - Calendar summary: 'Generate a new public access token for a calendar' operationId: newCalendarAccessToken parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Calendar object with new access token' '400': description: 'Missing or invalid calendar ID' '401': description: Unauthorized '403': description: 'AddEvents role required' security: - ApiKeyAuth: [] '/calendars/{id}/AccessToken': delete: tags: - Calendar summary: 'Remove the public access token from a calendar' operationId: deleteCalendarAccessToken parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Calendar object with access token cleared' '400': description: 'Missing or invalid calendar ID' '401': description: Unauthorized '403': description: 'AddEvents role required' security: - ApiKeyAuth: [] /calendars/pinnable: get: tags: - Calendar summary: 'Calendars the caller may pin an event of a ministry to' description: "Volunteer v2 (D25). With the global AddEvent right, every calendar. Without it, the ministry's own calendar and the church calendars an administrator opened to that ministry, when the caller manages the ministry; otherwise none. The event editor offers exactly these." operationId: getPinnableCalendars parameters: - name: ministryId in: query description: "The event's volunteer ministry; omit it for an event with none" required: false schema: type: integer example: 3 responses: '200': description: 'The calendar ids' content: application/json: schema: properties: ministryId: { type: integer, example: 3, nullable: true } calendarIds: { type: array, items: { type: integer }, example: [7, 12] } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] '/calendars/{id}/ministries': get: tags: - Calendar summary: 'The volunteer ministries a church calendar is opened to' description: "Volunteer v2 (D25). A coordinator of one of these ministries may pin that ministry's events to the calendar without the AddEvent right." operationId: getCalendarMinistryGrants parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'The grants' content: application/json: schema: properties: calendarId: { type: integer, example: 7 } ministries: { type: array, items: { properties: { id: { type: integer, example: 3 }, name: { type: string, example: "Children's Ministry" }, active: { type: boolean, example: true } }, type: object } } type: object '401': description: Unauthorized '403': description: 'AddEvents role required, or Volunteer Management V2 is not enabled' '404': description: 'Calendar not found' security: - ApiKeyAuth: [] put: tags: - Calendar summary: 'Open a church calendar to exactly these volunteer ministries' description: "Volunteer v2 (D25). Replaces the calendar's grants; an empty list closes it to every ministry. Managing the calendar itself (name, colours, public link) is unaffected." operationId: putCalendarMinistryGrants parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - ministryIds properties: ministryIds: type: array items: { type: integer } example: [3, 5] type: object responses: '200': description: 'The grants now in force, shaped like GET /calendars/{id}/ministries' '400': description: 'ministryIds missing or not a list of ids, or an unknown ministry' '401': description: Unauthorized '403': description: 'AddEvents role required, or Volunteer Management V2 is not enabled' '404': description: 'Calendar not found' '409': description: 'The calendar belongs to a ministry; only church calendars are opened to ministries' security: - ApiKeyAuth: [] /calendar/events-counters: get: tags: - Calendar summary: "Get today's event counters for the dashboard menu badges" description: 'Returns counts of upcoming birthdays, anniversaries, and events used to populate navigation badge counts.' operationId: getEventsCounters responses: '200': description: 'Counter object' content: application/json: schema: properties: birthdays: { type: integer, example: 2 } anniversaries: { type: integer, example: 1 } events: { type: integer, example: 3 } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] /background/timerjobs: post: tags: - System summary: 'Trigger background timer jobs (page-load fallback scheduler)' description: 'Fallback trigger fired from the page footer on every authenticated page load, for installs that cannot run cron. The jobs are rate limited server-side by iTimerJobsMinIntervalMinutes, so a call inside that window returns ran=false without doing any work. The supported scheduler is the command-line runner (cli/timerjobs.php) driven by cron.' requestBody: required: false content: application/json: schema: properties: force: description: "Administrators only: run now even inside the minimum interval, and top up the volunteer schedules even if today's top-up has run" type: boolean type: object responses: '200': description: 'Timer jobs executed, or skipped by the rate limit' content: application/json: schema: properties: ran: { description: 'False when the rate limit skipped this call', type: boolean, example: true } lastRun: { description: 'When the jobs last completed', type: string, example: '2026-09-11 08:15:00', nullable: true } minIntervalMinutes: { type: integer, example: 15 } type: object security: - ApiKeyAuth: [] /geocoder/address: post: tags: - Map summary: 'Geocode an address — returns latitude and longitude' requestBody: required: true content: application/json: schema: properties: address: description: 'Full address string to geocode' type: string type: object responses: '200': description: 'Latitude and longitude for the provided address' content: application/json: schema: properties: Latitude: { type: number, format: float } Longitude: { type: number, format: float } type: object '400': description: 'Empty or invalid request body' security: - ApiKeyAuth: [] /deposits: get: tags: - Finance summary: 'Get all deposits (Finance role required)' parameters: - name: fyid in: query description: 'Fiscal year ID to filter by deposit date; omit for all time' required: false schema: type: integer responses: '200': description: 'JSON array of deposits' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] post: tags: - Finance summary: 'Create a new deposit (Finance role required)' requestBody: required: true content: application/json: schema: required: - depositType properties: depositType: type: string enum: [Bank, CreditCard, BankDraft] depositComment: type: string depositDate: type: string format: date type: object responses: '200': description: 'Newly created deposit object' '400': description: 'Invalid or missing deposit type' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] /deposits/dashboard: get: tags: - Finance summary: 'Get deposits from the last 90 days (Finance role required)' responses: '200': description: 'Array of deposits within the last 90 days' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/deposits/{id}': get: tags: - Finance summary: 'Get a single deposit by ID (Finance role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Deposit object' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] post: tags: - Finance summary: 'Update an existing deposit (Finance role required)' parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: depositType: type: string enum: [Bank, CreditCard, BankDraft] depositComment: type: string depositDate: type: string format: date depositClosed: type: boolean type: object responses: '200': description: 'Updated deposit object' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] delete: tags: - Finance summary: 'Delete a deposit (Finance role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Deposit deleted successfully' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/deposits/{id}/ofx': get: tags: - Finance summary: 'Get OFX export data for a deposit (Finance role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'OFX content for the deposit' '404': description: 'Deposit not found' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/deposits/{id}/pdf': get: tags: - Finance summary: 'Generate and stream a PDF report for a deposit (Finance role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'PDF generated successfully' '404': description: 'Deposit not found or has no payments' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] /deposits/csv: get: tags: - Finance summary: 'Bulk-download a RFC 4180-compliant CSV of payments for multiple deposits (Finance role required)' parameters: - name: ids in: query description: "Comma-separated list of deposit IDs, e.g. '1,2,3'" required: true schema: type: string responses: '200': description: 'CSV file attachment with payment data for all requested deposits' '400': description: 'No valid deposit IDs provided' '404': description: 'No payments found for the requested deposits' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/deposits/{id}/csv': get: tags: - Finance summary: 'Download a RFC 4180-compliant CSV of payments for a single deposit (Finance role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'CSV file attachment with payment data' '404': description: 'No payments found for this deposit' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/deposits/{id}/pledges': get: tags: - Finance summary: 'Get pledge items for a deposit (Finance role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Pledge items associated with the deposit' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/deposits/{id}/payments': get: tags: - Finance summary: 'Get payment items for a deposit (Finance role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Payment items associated with the deposit' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] /deposits/open-count: get: tags: - Finance summary: 'Get count of open (non-closed) deposits (Finance role required)' responses: '200': description: 'Open deposit count' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] /donation-funds: get: tags: - Finance summary: 'List all donation funds (Finance role required)' parameters: - name: activeOnly in: query description: 'When true, return only active funds' required: false schema: type: boolean responses: '200': description: 'Array of donation funds' content: application/json: schema: properties: funds: { type: array, items: { properties: { id: { type: integer }, name: { type: string }, description: { type: string }, active: { type: boolean }, order: { type: integer } }, type: object } } type: object '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/donation-funds/{id}': get: tags: - Finance summary: 'Get a single donation fund (Finance role required)' parameters: - name: id in: path description: 'Fund ID' required: true schema: type: integer responses: '200': description: 'Donation fund object' '404': description: 'Fund not found' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] /fiscalyear: get: tags: - Finance summary: 'Resolve fiscal year ID for a given date (Finance role required)' description: 'Returns the fiscal year ID and label for the supplied date, respecting the iFYMonth system setting. Falls back to the current fiscal year when no date is supplied.' parameters: - name: date in: query description: 'Calendar date to resolve (YYYY-MM-DD). Defaults to today.' required: false schema: type: string format: date example: '2025-03-15' responses: '200': description: 'Fiscal year details for the supplied date' content: application/json: schema: properties: fyId: { description: 'Fiscal year ID', type: integer, example: 29 } label: { description: 'Human-readable fiscal year label', type: string, example: '2025' } type: object '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] /fundraisers: get: tags: - Finance summary: 'List all fundraisers (Manage Fundraisers role required)' responses: '200': description: 'Array of fundraiser objects' '401': description: Unauthorized '403': description: 'Manage Fundraisers role required' security: - ApiKeyAuth: [] post: tags: - Finance summary: 'Create a new fundraiser (Manage Fundraisers role required)' requestBody: required: true content: application/json: schema: required: - title properties: title: type: string maxLength: 128 description: type: string date: type: string format: date type: object responses: '201': description: 'Newly created fundraiser object' '400': description: 'Validation error' '401': description: Unauthorized '403': description: 'Manage Fundraisers role required' security: - ApiKeyAuth: [] '/fundraisers/{id}': get: tags: - Finance summary: 'Get a single fundraiser (Manage Fundraisers role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Fundraiser object' '404': description: 'Fundraiser not found' '401': description: Unauthorized '403': description: 'Manage Fundraisers role required' security: - ApiKeyAuth: [] put: tags: - Finance summary: 'Update a fundraiser (Manage Fundraisers role required)' parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: title: type: string maxLength: 128 description: type: string date: type: string format: date type: object responses: '200': description: 'Updated fundraiser object' '400': description: 'Validation error' '404': description: 'Fundraiser not found' '401': description: Unauthorized '403': description: 'Manage Fundraisers role required' security: - ApiKeyAuth: [] delete: tags: - Finance summary: 'Delete a fundraiser (Manage Fundraisers role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Fundraiser deleted' '404': description: 'Fundraiser not found' '409': description: 'Fundraiser still has associated donated items' '401': description: Unauthorized '403': description: 'Manage Fundraisers role required' security: - ApiKeyAuth: [] /payments/: get: tags: - Finance summary: 'Get all payments (Finance role required)' responses: '200': description: 'Array of payment records' content: application/json: schema: properties: payments: { type: array, items: { type: object } } type: object '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] post: tags: - Finance summary: 'Submit a new pledge or payment (Finance role required)' requestBody: required: true content: application/json: schema: description: 'Pledge or payment fields (FamilyId, FundId, Amount, Date, DepositId, etc.)' responses: '200': description: 'Created pledge or payment record' content: application/json: schema: properties: payment: { type: object } type: object '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/payments/family/{familyId}/list': get: tags: - Finance summary: 'Get pledge and payment history for a family (Finance role required)' description: "Results are filtered by fyid (fiscal year) when a positive value is provided. fyid=0 means All Time (no date filter at all). Omitting fyid entirely falls back to the current user's ShowSince date preference. ShowPayments/ShowPledges preferences always apply." parameters: - name: familyId in: path required: true schema: type: integer - name: fyid in: query description: 'Fiscal year ID to filter by; 0 for All Time (no filter); omit to fall back to the ShowSince preference' required: false schema: type: integer responses: '200': description: 'Pledge/payment rows for the family' content: application/json: schema: properties: data: { type: array, items: { properties: { FormattedFY: { type: string }, Amount: { type: number }, Amount_formatted: { type: string }, Nondeductible: { type: number }, Nondeductible_formatted: { type: string }, PledgeOrPayment: { type: string }, Date: { type: string, format: date }, Fund: { type: string } }, type: object } } type: object '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/payments/pledges/{groupKey}': get: tags: - Finance summary: 'Get pledge/payment details by group key (Finance role required)' description: 'Returns all rows sharing the same GroupKey, including per-fund amounts and family information.' parameters: - name: groupKey in: path description: 'The pledge group key (plg_GroupKey)' required: true schema: type: string responses: '200': description: 'Pledge group details' content: application/json: schema: properties: groupKey: { type: string, example: abc123 } familyId: { description: 'Family ID; 0 or null for anonymous donations', type: integer, example: 42, nullable: true } familyName: { description: 'Family display name; empty string for anonymous donations', type: string, example: 'Smith Family' } date: { type: string, format: date, example: '2025-01-15' } fyId: { type: integer, example: 29 } method: { type: string, example: CHECK } checkNo: { type: string, example: '1234', nullable: true } depositId: { type: integer, example: 5, nullable: true } pledgeOrPayment: { type: string, example: Payment, enum: [Pledge, Payment] } schedule: { type: string, example: Monthly, nullable: true } total: { type: number, format: float, example: 250 } total_formatted: { type: string, example: $250.00 } funds: { type: array, items: { properties: { fundId: { type: integer, example: 1 }, fundName: { type: string, example: 'General Fund' }, amount: { type: number, format: float, example: 200 }, amount_formatted: { type: string, example: $200.00 }, nonDeductible: { type: number, format: float, example: 0 }, nonDeductible_formatted: { type: string, example: $0.00 }, comment: { type: string, example: 'Annual pledge' } }, type: object } } type: object '400': description: 'Invalid group key' '401': description: Unauthorized '403': description: 'Finance role required' '404': description: 'Pledge group not found' security: - ApiKeyAuth: [] '/payments/{groupKey}': put: tags: - Finance summary: 'Update an existing pledge or payment group (Finance role required)' description: 'Replaces the fund rows for the given GroupKey with the submitted FundSplit. Supports the same multi-fund split fields as POST /payments/pledges.' parameters: - name: groupKey in: path description: 'The pledge group key (plg_GroupKey)' required: true schema: type: string requestBody: required: true content: application/json: schema: required: - Date - FYID - type - FundSplit - iMethod properties: FamilyID: description: 'Family ID — omit or pass null for anonymous/cash donations with no associated family' type: integer example: 42 nullable: true Date: description: 'Pledge/payment date' type: string format: date example: '2025-01-15' FYID: description: 'Fiscal year ID' type: integer example: 29 type: description: 'Record type' type: string example: Payment enum: [Pledge, Payment] iMethod: description: 'Payment method' type: string example: CHECK enum: [CHECK, CASH, CREDITCARD, BANKDRAFT] FundSplit: description: 'JSON-encoded array of fund allocations' type: string type: object responses: '200': description: 'Updated pledge/payment group key and details' content: application/json: schema: properties: groupKey: { type: string, example: abc123 } payment: { description: 'Pledge details object', type: object } type: object '400': description: 'Validation error (invalid date, fund, check number, etc.)' '401': description: Unauthorized '403': description: 'Finance role required' '404': description: 'Pledge group not found' '409': description: 'Deposit is closed — payment cannot be edited' security: - ApiKeyAuth: [] delete: tags: - Finance summary: 'Delete a payment by group key (Finance role required)' description: 'Deletes the pledge or payment group and all associated fund allocations.' parameters: - name: groupKey in: path description: 'The pledge group key (plg_GroupKey)' required: true schema: type: string responses: '200': description: 'Pledge group deleted successfully' '400': description: 'Invalid or missing group key' '401': description: Unauthorized '403': description: 'Finance role required' '404': description: 'Pledge group not found' security: - ApiKeyAuth: [] /email/send: post: tags: - Email summary: 'Send a composer email, one message per recipient, through the configured SMTP server' description: 'Recipients are given as person and family ids, never as addresses: the server resolves each id to its current email address, applies the do-not-email property, skips deceased people, inactive families and ids without an address, deduplicates addresses, and reports every skipped recipient by name. Each recipient receives their own message.' requestBody: required: true content: application/json: schema: required: - subject - body properties: personIds: description: 'Person ids to email at their primary address (falls back to the family address)' type: array items: { type: integer } familyIds: description: 'Family ids to email at the family address' type: array items: { type: integer } subject: description: 'Subject line (plain text)' type: string maxLength: 255 body: description: 'Plain-text body; newlines are preserved' type: string type: object responses: '200': description: 'Per-recipient outcome' content: application/json: schema: properties: sent: { description: 'Recipients whose message the SMTP server accepted (acceptance is not delivery)', type: array, items: { properties: { personId: { type: integer, nullable: true }, familyId: { type: integer, nullable: true }, name: { type: string }, email: { type: string } }, type: object } } skipped: { description: 'Requested recipients that were not emailed', type: array, items: { properties: { personId: { type: integer, nullable: true }, familyId: { type: integer, nullable: true }, name: { type: string }, reason: { type: string, enum: [not-found, no-email, do-not-email, deceased, inactive, duplicate-address] } }, type: object } } failed: { description: 'Recipients the SMTP server rejected', type: array, items: { properties: { personId: { type: integer, nullable: true }, familyId: { type: integer, nullable: true }, name: { type: string }, email: { type: string }, error: { type: string } }, type: object } } counts: { properties: { sent: { type: integer }, skipped: { type: integer }, failed: { type: integer } }, type: object } type: object '400': description: 'Invalid request: raw addresses, no ids, bad ids, more than 500 ids, or empty subject/body' '401': description: Unauthorized '403': description: 'Email permission required' '422': description: 'Email sending is disabled or SMTP is not configured' security: - ApiKeyAuth: [] /email/preview: post: tags: - Email summary: 'Render a composer message for its first recipient without sending it' requestBody: required: true content: application/json: schema: required: - subject - body properties: personIds: type: array items: { type: integer } familyIds: type: array items: { type: integer } subject: type: string body: type: string type: object responses: '200': description: 'The rendered HTML and who it is addressed to' content: application/json: schema: properties: html: { type: string } recipient: { properties: { name: { type: string }, email: { type: string } }, type: object } recipientCount: { description: 'How many recipients the send would reach', type: integer } type: object '400': description: 'No resolvable recipient, or empty subject/body' '401': description: Unauthorized '403': description: 'Email permission required' security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/events': get: tags: - Volunteer summary: 'The events a ministry owns, with their staffing and headcount (D24, D26)' description: 'Events whose event_ministry_id is this ministry, inactive ones included. Upcoming by default (from today, a year ahead, soonest first); past=1 lists the year before today, newest first. from/to override either bound. Each event carries its type, pinned calendars, Linked Groups, headcount total and, per team of this ministry, the staffing of its scheduled occurrences anchored to the event, from the same gap derivation as the occurrence list.' operationId: listVolunteerMinistryEvents parameters: - name: ministryId in: path required: true schema: type: integer - name: from in: query required: false schema: type: string format: date - name: to in: query required: false schema: type: string format: date - name: past in: query required: false schema: type: boolean responses: '200': description: OK content: application/json: schema: properties: events: { type: array, items: { properties: { id: { type: integer }, title: { type: string }, start: { type: string }, end: { type: string }, eventTypeId: { type: integer }, eventTypeName: { type: string, nullable: true }, inactive: { type: boolean }, calendars: { type: array, items: { type: object } }, linkedGroups: { type: array, items: { type: object } }, headcount: { properties: { recorded: { type: boolean }, total: { type: integer } }, type: object }, otherStaffing: { description: 'D28: other ministries with an occurrence on the event and how many of their volunteers are assigned (pending or accepted)', type: array, items: { properties: { ministryId: { type: integer }, ministryName: { type: string }, assigned: { type: integer } }, type: object } }, staffing: { type: array, items: { properties: { teamId: { type: integer }, teamName: { type: string }, occurrenceIds: { type: array, items: { type: integer } }, needed: { type: integer }, filled: { type: integer }, pending: { type: integer }, gap: { type: integer }, openCount: { description: "D34: room left, summed over the team's positions (each position's max minus its live assignments, never below 0)", type: integer }, capacity: { description: 'D34: the summed maximums; a position with no maximum counts its minimum (a NULL MaxCount means the same as MinCount)', type: integer }, requirementCount: { type: integer }, status: { type: string, enum: [unplanned, gap, pending, filled] } }, type: object } } }, type: object } } from: { type: string, format: date } to: { type: string, format: date } past: { type: boolean } limit: { type: integer } capped: { type: boolean } type: object '400': description: 'A malformed or inverted window' '401': description: 'Not authenticated' '403': description: 'Not a coordinator of this ministry, or V2 is not enabled' '404': description: 'No such ministry' security: - ApiKeyAuth: [] post: tags: - Volunteer summary: "Create a ministry's event or event series through core (D24)" description: "One event (date, startTime, endTime) through the same code as POST /events, or a series (recurrence, rangeStart, rangeEnd, startTime, endTime) through the repeat engine, capped at 366 events; rangeEnd is required. Every event carries this ministry's id and the Linked Group when given, and is pinned to calendarIds (default: the ministry's own calendar), each of which must be the ministry's own calendar or a church calendar opened to it unless the caller holds Add Events (D25). Nothing is written when any part is refused. Staffing is a separate step (D33): a staff key is refused; a series is staffed with POST /ministries/ministries/{ministryId}/events/staff, one event with Staff this event. Fires event.created for each event after the commit." operationId: createVolunteerMinistryEvents parameters: - name: ministryId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - title - eventTypeId - startTime - endTime properties: title: type: string example: Workday eventTypeId: type: integer example: 1 description: type: string nullable: true linkedGroupId: description: 'The Linked Group, usually a Sunday School class' type: integer nullable: true calendarIds: description: "Omit for the ministry's own calendar" type: array items: { type: integer } date: description: 'One event' type: string format: date startTime: type: string example: '09:00' endTime: type: string example: '12:00' recurrence: description: 'A series instead of one event' properties: { type: { type: string, enum: [weekly, monthly, yearly] }, dow: { type: string, example: Sunday }, dom: { type: integer, example: 15 }, doy: { type: string, example: 04-12 } } type: object rangeStart: type: string format: date rangeEnd: description: 'Required for a series' type: string format: date type: object responses: '201': description: Created content: application/json: schema: properties: events: { type: array, items: { properties: { id: { type: integer }, title: { type: string }, start: { type: string }, end: { type: string } }, type: object } } type: object '400': description: 'A missing or malformed field, a staff key (D33), an unknown type, class or calendar, or a recurrence with no date in the range or over the cap' '401': description: 'Not authenticated' '403': description: 'Not a coordinator of this ministry (team leaders and self-service logins included), a calendar the ministry may not pin to, or V2 is not enabled' '404': description: 'No such ministry' security: - ApiKeyAuth: [] delete: tags: - Volunteer summary: 'Delete events this ministry owns (D28)' description: "The Calendar tab's Delete events. Every id must be an event whose event_ministry_id is this ministry, and each is deleted through core's event delete in one transaction - all or none. Refused while another ministry has volunteers assigned (pending or accepted) on any of them unless the caller holds Add Events, and whenever core refuses one (people checked in, a kiosk assigned)." operationId: deleteVolunteerMinistryEvents parameters: - name: ministryId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - eventIds properties: eventIds: type: array items: { type: integer } type: object responses: '200': description: Deleted content: application/json: schema: properties: deleted: { type: integer } type: object '400': description: 'eventIds is missing, empty or not a list of ids' '401': description: 'Not authenticated' '403': description: 'Not a coordinator of this ministry, an event another ministry or nobody owns, a self-service login, or V2 is not enabled' '404': description: 'No such ministry or event' '409': description: 'Another ministry has volunteers on one of the events (`ministries` names them), or core refused one (the reason and `eventId`); nothing is deleted' security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/events/staff': post: tags: - Volunteer summary: "Staff new events on the ministry's schedules that already follow them (D33)" description: "The Staff them answer after a series was created. The events must be this ministry's and one series (one title, type and class). Every active schedule of the ministry that is not a Staff this event schedule and follows them - class mode on their class, ministry mode with exactly their title, or event_type mode with their type and exactly their title, titles ignoring case - takes them: its window is widened to cover them (the first date moved back, a set last date moved on, an open end left open), it is generated through the last event but never past the scheduling horizon, and its saved default volunteers are assigned on the occurrences that run created. An empty list means no schedule follows them: nothing was made. Matching is by class and title because core events carry no series id." operationId: staffVolunteerMinistryEvents parameters: - name: ministryId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - eventIds properties: eventIds: type: array items: { type: integer } type: object responses: '200': description: 'The schedules that took the events, each with its run' content: application/json: schema: properties: schedules: { type: array, items: { properties: { schedule: { type: object }, widened: { description: 'Its first or last date moved to cover the events', type: boolean }, created: { type: integer }, existing: { type: integer }, from: { type: string, format: date }, through: { type: string, format: date }, assigned: { type: integer }, skipped: { type: integer }, unqualified: { type: integer } }, type: object } } type: object '400': description: 'eventIds is missing, empty or not a list of ids, the events are not one series, or a run would exceed the occurrence cap' '401': description: 'Not authenticated' '403': description: 'Not a coordinator of this ministry (team leaders and self-service logins included), an event the ministry does not own, or V2 is not enabled' '404': description: 'No such ministry or event' security: - ApiKeyAuth: [] '/ministries/positions/{positionId}/eligible': get: tags: - Volunteer summary: 'Who may fill a position on a schedule that is about to be created' description: "The schedule dialog's Default volunteer picker (D32), which may run before the schedule exists: the same rotation-ordered list as /schedules/{id}/eligible, for the position's own team." operationId: listVolunteerPositionEligiblePeople parameters: - name: positionId in: path required: true schema: type: integer - name: q in: query required: false schema: type: string responses: '200': description: OK content: application/json: schema: properties: people: { type: array, items: { type: object } } type: object '401': description: 'Not authenticated' '403': description: 'Not authorized for this position, or V2 is not enabled' '404': description: 'No such position' security: - ApiKeyAuth: [] '/ministries/teams/{teamId}/class-events': get: tags: - Volunteer summary: "The events this team's ministry owns for the team's class (D28)" description: "What the team dialog asks about before the class changes or the team is deleted: how many events whose event_ministry_id is the team's ministry have the team's current class as Linked Group, how many of them are today or later, and which other ministries staff them. Events an administrator created are not counted. All zero for a team with no class." operationId: getVolunteerTeamClassEvents parameters: - name: teamId in: path required: true schema: type: integer responses: '200': description: OK content: application/json: schema: properties: teamId: { type: integer } ministryId: { type: integer } ministryName: { type: string } classGroupId: { type: integer, nullable: true } classGroupName: { type: string, nullable: true } total: { type: integer } upcoming: { type: integer } otherStaffing: { type: array, items: { properties: { ministryId: { type: integer }, ministryName: { type: string }, assigned: { type: integer }, eventCount: { type: integer } }, type: object } } type: object '401': description: 'Not authenticated' '403': description: 'Not authorized for this team, or V2 is not enabled' '404': description: 'No such team' security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/schedules': get: tags: - Volunteer summary: "List a ministry's volunteer schedules" operationId: listVolunteerSchedules parameters: - name: ministryId in: path required: true schema: type: integer - name: teamId in: query description: 'Only schedules owned by this team' required: false schema: type: integer - name: active in: query description: 'Only active schedules' required: false schema: type: boolean responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry' '200': description: OK content: application/json: schema: properties: schedules: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] post: tags: - Volunteer summary: 'Create a volunteer schedule: which calendar events a team staffs' description: 'Every occurrence is anchored to a calendar event (D20); the link mode says how the events are found (D22). A schedule follows events that already exist (D31): it is refused unless at least one upcoming active event matches. A new schedule generates its occurrences at once, up to the scheduling horizon, and assigns the default volunteers its staffing needs carry on them (D33, D32); `generated` is that run, shaped as POST /ministries/schedules/{scheduleId}/generate answers, and null for a schedule saved inactive, which makes none. A single event is staffed through POST /ministries/ministries/{ministryId}/staffed-events instead. The removed fields (recurType, recurDow, recurDom, startTime, endTime, generateAheadDays) are refused with 400.' operationId: createVolunteerSchedule parameters: - name: ministryId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - name - linkMode - teamId - windowStart properties: name: type: string linkMode: description: 'event_type: events of a type with exactly the title titleFilter. class: events whose Linked Group is groupId. ministry: events this ministry owns with exactly the title titleFilter.' type: string enum: [event_type, class, ministry] teamId: description: "Required: a schedule always belongs to one of the ministry's teams. A team leader may only name a team they lead (design section 4.6)." type: integer eventTypeId: description: 'Required when linkMode is event_type' type: integer nullable: true titleFilter: description: "Required in the event_type and ministry modes: the events' exact title, compared ignoring case (D31)" type: string nullable: true groupId: description: 'Required when linkMode is class: an existing group' type: integer nullable: true startOffsetMinutes: description: "Added to the event's start to give the volunteers' start; within +/-720. Default 0." type: integer example: -45 endOffsetMinutes: description: "Added to the event's end to give the volunteers' end; within +/-720. Default 0." type: integer example: 15 windowStart: type: string format: date windowEnd: type: string format: date nullable: true requirements: description: "The schedule's whole staffing plan, set in one request. Omit the field to leave the plan alone; send an empty array to clear it. Positions not listed are removed. The schedule row and its plan are written in one transaction." type: array items: { required: [positionId, minCount], properties: { positionId: { type: integer }, minCount: { type: integer, example: 1 }, maxCount: { type: integer, example: 1, nullable: true }, notes: { type: string, nullable: true }, defaults: { description: "D32/D35: the position's default volunteers in the order they are assigned. Absent keeps the stored ones, an empty list clears them. At most the position's Max (its Min when Max is blank), never more than 50, each person once; a new one must hold an active qualification, a stored one named again is kept even when it no longer does. The removed defaultPersonId and defaultAccepted fields are refused (400).", type: array, items: { required: [personId], properties: { personId: { type: integer }, accepted: { description: 'Recorded as accepted instead of being asked to respond. Left out: the stored flag, false for a new default', type: boolean } }, type: object } } }, type: object } nullable: true type: object responses: '400': description: 'A design section 2.8 invariant was violated: a missing or unknown event type or group, a missing title, no upcoming event that matches (D31), an offset outside +/-720, an eventId, or a removed field; or a staffing need with more default volunteers than its Max, or one person twice' '401': description: 'Not authenticated' '403': description: 'Not authorized: you neither administer this ministry nor lead the team named by teamId (design section 4.6), a new default volunteer is not qualified, or V2 is not enabled' '404': description: 'No such ministry, or a default volunteer names an unknown person' '201': description: 'Created, with its occurrences generated' content: application/json: schema: properties: schedule: { type: object } generated: { description: 'The run Save made (D33): created, existing, from, through, assigned, skipped, unqualified, noEvents, searched; null when saved inactive', type: object, nullable: true } type: object security: - ApiKeyAuth: [] '/ministries/teams/{teamId}/schedules': get: tags: - Volunteer summary: "List one team's volunteer schedules" description: "The team-keyed twin of the ministry list, gated per team so a team leader can see their own schedules without being authorized for the ministry above them (design section 4.4). Added for the Member Portal's My Teams page (#9868)." operationId: listVolunteerTeamSchedules parameters: - name: teamId in: path required: true schema: type: integer - name: active in: query description: 'Only active schedules' required: false schema: type: boolean responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this team, or V2 is not enabled' '404': description: 'No such team' '200': description: OK content: application/json: schema: properties: schedules: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/ministries/schedules/{scheduleId}': get: tags: - Volunteer summary: 'Read one volunteer schedule' operationId: getVolunteerSchedule parameters: - name: scheduleId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this schedule, or V2 is not enabled' '404': description: 'No such schedule' '200': description: OK security: - ApiKeyAuth: [] post: tags: - Volunteer summary: 'Update a volunteer schedule' operationId: updateVolunteerSchedule parameters: - name: scheduleId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: description: 'Any subset of the create payload; omitted fields keep their stored value, and the columns the link mode does not use are cleared. A `requirements` array replaces the whole staffing plan — positions not listed are removed, an empty array clears it, and an absent field leaves it untouched. A Staff this event schedule keeps its link mode and its event. Changing what the schedule follows (mode, type, title, class) needs an upcoming event that matches (D31); other edits never do, but a type or ministry schedule with no title must be given one.' type: object responses: '400': description: 'A design section 2.8 invariant was violated, or the new events it would follow have no upcoming date (D31)' '401': description: 'Not authenticated' '403': description: 'Not authorized for this schedule, or V2 is not enabled' '404': description: 'No such schedule' '200': description: Updated security: - ApiKeyAuth: [] delete: tags: - Volunteer summary: 'Delete a volunteer schedule and its generated occurrences' operationId: deleteVolunteerSchedule parameters: - name: scheduleId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this schedule, or V2 is not enabled' '404': description: 'No such schedule' '409': description: 'Occurrences of this schedule still carry assignments' '200': description: Deleted security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/staffed-events': post: tags: - Volunteer summary: 'Staff this event: one calendar event, one team, one occurrence' description: 'Creates a hidden schedule in the event link mode (never listed on the Schedules tab, deleted together with its occurrence) carrying the team, the staffing needs and the optional offsets, and the one occurrence anchored to the event, in one transaction (D22). Any upcoming active event may be staffed: anchoring is a read-only reference. Same gate as creating a schedule.' operationId: createVolunteerStaffedEvent parameters: - name: ministryId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - eventId - teamId properties: eventId: type: integer teamId: type: integer name: description: "Defaults to the event's title" type: string nullable: true startOffsetMinutes: description: 'Within +/-720; default 0' type: integer nullable: true endOffsetMinutes: description: 'Within +/-720; default 0' type: integer nullable: true requirements: type: array items: { properties: { positionId: { type: integer }, minCount: { type: integer }, maxCount: { type: integer, nullable: true } }, type: object } type: object responses: '400': description: 'Unknown, inactive or past event, a team of another ministry, an offset outside +/-720, or a staffing row naming an unknown position' '401': description: 'Not authenticated' '403': description: 'Not a coordinator of the ministry nor a leader of the team, or V2 is not enabled' '404': description: 'No such ministry' '409': description: 'This team is already staffing this event' '201': description: Created content: application/json: schema: properties: occurrence: { type: object } schedule: { type: object } type: object security: - ApiKeyAuth: [] '/ministries/schedules/{scheduleId}/eligible': get: tags: - Volunteer summary: 'Who may fill a position on the occurrences this schedule is about to generate' description: "The Generate Occurrences dialog's 'Fill by default with' picker. The same list as /occurrences/{id}/eligible - actively qualified people, least recently served first, inPool reported rather than filtered on - minus the double-duty annotation, since no occurrence exists yet." operationId: listVolunteerScheduleEligiblePeople parameters: - name: scheduleId in: path required: true schema: type: integer - name: positionId in: query required: true schema: type: integer - name: q in: query description: 'Case-insensitive name filter' required: false schema: type: string responses: '400': description: 'positionId is missing, unknown, or belongs to another team' '401': description: 'Not authenticated' '403': description: 'Not authorized for this schedule, or V2 is not enabled' '404': description: 'No such schedule' '200': description: OK content: application/json: schema: properties: people: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/ministries/schedules/{scheduleId}/generate': post: tags: - Volunteer summary: "Materialise this schedule's occurrences up to a date" description: "Idempotent. Attaches one occurrence to each active event the schedule follows (by type, class, ministry or the one event) inside the window, from today up to the scheduling horizon (D31; a Staff this event schedule is not capped by it). No calendar event is ever created or changed. `requirements[].defaults` (D32, D35) is saved on the schedule's staffing needs first — per position, the whole list in the order they are assigned, an empty list clearing it, a position left out keeping its own; the counts are not changed — and then every saved default is assigned, in that order, on the occurrences THIS run creates (never on ones an earlier run made), while its person holds an active qualification; with accepted=true they are recorded as having accepted and are not asked to respond. Without `requirements` the saved ones are assigned unchanged. The old `defaults` key is refused (400)." operationId: generateVolunteerOccurrences parameters: - name: scheduleId in: path required: true schema: type: integer requestBody: required: false content: application/json: schema: properties: through: description: "Defaults to, and may not go past, today plus the scheduling horizon (iVolunteerSchedulingHorizonWeeks); the schedule's last date caps it too" type: string format: date nullable: true requirements: type: array items: { required: [positionId], properties: { positionId: { description: "A position this schedule's staffing needs ask for", type: integer }, defaults: { description: "The position's default volunteers in the order they are assigned; at most its Max (its Min when Max is blank), and never more than 50", type: array, items: { required: [personId], properties: { personId: { type: integer }, accepted: { description: 'Left out: the stored flag, false for a new default', type: boolean } }, type: object } } }, type: object } type: object responses: '400': description: "Malformed date, the run would exceed the occurrence cap, the old defaults key, a position this schedule's staffing needs do not ask for, a person named twice, or more defaults than the position's Max" '401': description: 'Not authenticated' '403': description: 'Not authorized for this schedule, a new default is not qualified, or V2 is not enabled' '404': description: 'No such schedule, or a default names an unknown person' '409': description: 'The schedule is inactive: reactivate it first' '200': description: OK content: application/json: schema: properties: created: { type: integer } existing: { type: integer } from: { description: 'First date looked at: the later of today and the window start; after through when the window has ended or not started', type: string, format: date } through: { type: string, format: date } assigned: { description: 'Default assignments written', type: integer } skipped: { description: 'Default assignments the assignment rules refused on one occurrence (over, cancelled, no room)', type: integer } unqualified: { description: 'Default assignments left open because the default no longer holds an active qualification (D32)', type: integer } noEvents: { description: 'No event at all was found between from and through (D30)', type: boolean } searched: { description: 'What the schedule looks for, with names (D30)', properties: { linkMode: { type: string, enum: [event_type, class, ministry, event] }, groupId: { type: integer, nullable: true }, groupName: { type: string, nullable: true }, eventTypeId: { type: integer, nullable: true }, eventTypeName: { type: string, nullable: true }, titleFilter: { type: string, nullable: true }, ministryId: { type: integer }, ministryName: { type: string, nullable: true }, eventId: { type: integer, nullable: true }, eventTitle: { type: string, nullable: true } }, type: object } type: object security: - ApiKeyAuth: [] '/ministries/schedules/{scheduleId}/requirements': get: tags: - Volunteer summary: "List a schedule's template staffing requirements" operationId: listVolunteerScheduleRequirements parameters: - name: scheduleId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this schedule, or V2 is not enabled' '404': description: 'No such schedule' '200': description: OK content: application/json: schema: properties: requirements: { type: array, items: { properties: { positionId: { type: integer }, minCount: { type: integer }, maxCount: { type: integer, nullable: true }, defaults: { description: 'D32/D35: the default volunteers, in the order they are assigned', type: array, items: { properties: { personId: { type: integer }, name: { type: string }, accepted: { type: boolean }, setBy: { description: 'Who chose the default; the daily top-up assigns in their name', type: integer, nullable: true }, setByName: { type: string, nullable: true }, qualified: { description: 'False while the qualification is revoked: generation then leaves the slot open', type: boolean } }, type: object } } }, type: object } } type: object security: - ApiKeyAuth: [] post: tags: - Volunteer summary: 'Create or update a template staffing requirement on a schedule' description: 'Upsert on the (schedule, position) unique key: re-posting the same position updates the counts on the existing row.' operationId: upsertVolunteerScheduleRequirement parameters: - name: scheduleId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - positionId - minCount properties: positionId: type: integer minCount: type: integer example: 1 maxCount: type: integer example: 1 nullable: true notes: type: string nullable: true type: object responses: '400': description: 'Unknown position, counts out of range, or a Max below the number of default volunteers the need already has (D35)' '401': description: 'Not authenticated' '403': description: 'Not authorized for this schedule, or V2 is not enabled' '404': description: 'No such schedule' '200': description: 'The requirement already existed and was updated' '201': description: Created security: - ApiKeyAuth: [] '/ministries/occurrences/{occurrenceId}/requirements': get: tags: - Volunteer summary: "This occurrence's effective staffing needs, and the positions it could need" description: "Everything the staffing-needs editor has to draw: the EFFECTIVE requirements from VolunteerScheduleService::getEffectiveRequirements() (each carrying `source`, so the caller can see which are the schedule's and which are this occurrence's own), plus the active positions of the owning team, so a position that has no requirement row can still be offered as an unchecked line. Served under the occurrence's own scope check, so a team leader can edit one week without reaching past their own team." operationId: listVolunteerOccurrenceRequirements parameters: - name: occurrenceId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this occurrence, or V2 is not enabled' '404': description: 'No such occurrence' '200': description: OK security: - ApiKeyAuth: [] post: tags: - Volunteer summary: 'Override a staffing requirement for one occurrence' description: "'This week we need four, not two.' The override wins over the schedule's template for that position only." operationId: upsertVolunteerOccurrenceRequirement parameters: - name: occurrenceId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - positionId - minCount properties: positionId: type: integer minCount: type: integer maxCount: type: integer nullable: true notes: type: string nullable: true type: object responses: '400': description: 'Unknown position, or counts out of range' '401': description: 'Not authenticated' '403': description: 'Not authorized for this occurrence, or V2 is not enabled' '404': description: 'No such occurrence' '200': description: 'The override already existed and was updated' '201': description: Created security: - ApiKeyAuth: [] delete: tags: - Volunteer summary: "Drop this occurrence's overrides so it follows the schedule again" description: "The 'use the schedule's needs' reset. Nothing was ever copied from the schedule at generation time — the merge is derived on every read (section 2.10) — so removing the override rows is the whole of the reset, and the occurrence immediately reflects the schedule's current plan, including requirements added long after it was generated." operationId: clearVolunteerOccurrenceRequirements parameters: - name: occurrenceId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this occurrence, or V2 is not enabled' '404': description: 'No such occurrence' '200': description: OK security: - ApiKeyAuth: [] '/ministries/occurrences/{occurrenceId}/requirements/replace': post: tags: - Volunteer summary: "Set this occurrence's whole staffing plan, overriding the schedule's" description: "Writes occurrence-level override rows that match the payload exactly: positions not listed lose their override. An empty array means 'this occurrence needs nobody' and is a real, storable answer — it is NOT the same as having no overrides, which means 'follow the schedule'. Use DELETE on the same path for that." operationId: replaceVolunteerOccurrenceRequirements parameters: - name: occurrenceId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - requirements properties: requirements: type: array items: { required: [positionId, minCount], properties: { positionId: { type: integer }, minCount: { type: integer }, maxCount: { type: integer, nullable: true }, notes: { type: string, nullable: true } }, type: object } type: object responses: '400': description: 'Unknown position, a duplicate position, or counts out of range' '401': description: 'Not authenticated' '403': description: 'Not authorized for this occurrence, or V2 is not enabled' '404': description: 'No such occurrence' '200': description: OK security: - ApiKeyAuth: [] '/ministries/requirements/{requirementId}': delete: tags: - Volunteer summary: 'Remove a staffing requirement' operationId: deleteVolunteerRequirement parameters: - name: requirementId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: "Not authorized for this requirement's schedule, or V2 is not enabled" '404': description: 'No such requirement' '200': description: Deleted security: - ApiKeyAuth: [] /ministries/occurrences: get: tags: - Volunteer summary: 'List occurrences inside a date window, scoped to the caller' description: '`from` and `to` are mandatory (design M9: no pagination protocol is invented for one module) and the result set is hard-capped.' operationId: listVolunteerOccurrences parameters: - name: from in: query required: true schema: type: string format: date - name: to in: query required: true schema: type: string format: date - name: ministryId in: query required: false schema: type: integer - name: teamId in: query description: 'Only the occurrences whose schedule belongs to this team.' required: false schema: type: integer - name: text in: query description: "Case-insensitive substring of the occurrence's schedule name or of its anchored event's title. The narrowing happens in the query; % and _ are escaped so they are matched literally rather than as LIKE wildcards." required: false schema: type: string maxLength: 100 - name: hasGaps in: query description: 'Return only occurrences that are short of at least one required volunteer (#9709). The filter is applied AFTER the counts are derived, because a gap is derived and there is nothing in the occurrence table to filter on.' required: false schema: type: boolean - name: scheduleId in: query required: false schema: type: integer - name: status in: query required: false schema: type: string enum: - scheduled - cancelled responses: '400': description: 'Missing, malformed or inverted window' '401': description: 'Not authenticated' '403': description: 'Volunteer coordinator access is required, or V2 is not enabled' '200': description: OK content: application/json: schema: properties: occurrences: { type: array, items: { properties: { requiredCount: { description: 'The summed minimums', type: integer }, requirementCount: { description: 'How many positions can take someone; 0 = no staffing needs set', type: integer }, liveCount: { type: integer }, gapCount: { description: 'The summed shortfall below each minimum', type: integer }, openCount: { description: 'The summed room left below each maximum', type: integer }, capacity: { description: 'D34: the summed maximums; a NULL MaxCount counts as its MinCount', type: integer }, pendingCount: { type: integer } }, type: object } } limit: { type: integer } capped: { type: boolean } type: object security: - ApiKeyAuth: [] '/ministries/occurrences/{occurrenceId}': get: tags: - Volunteer summary: 'Read one occurrence with its effective times and effective requirements' description: "The reported start and end are the anchored calendar event's, moved by the schedule's offsets (null once the event was deleted); the requirements are the occurrence's own overrides merged over the schedule's templates." operationId: getVolunteerOccurrence parameters: - name: occurrenceId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this occurrence, or V2 is not enabled' '404': description: 'No such occurrence' '200': description: OK content: application/json: schema: properties: occurrence: { type: object } requirements: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] delete: tags: - Volunteer summary: 'Delete one occurrence and everything under it' description: "The Occurrences tab's Delete for dates that should not have been generated. Requirement overrides, assignments and their responses, swaps and queued notifications go with it. Scoped like every occurrence route: a coordinator of the ministry or a leader of the schedule's team. A deleted occurrence can be regenerated from its event; cancel it instead to keep the row. Deleting the only occurrence of a Staff this event schedule deletes that hidden schedule too." operationId: deleteVolunteerOccurrence parameters: - name: occurrenceId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not in scope, or V2 is not enabled' '404': description: 'No such occurrence' '200': description: Deleted security: - ApiKeyAuth: [] '/ministries/occurrences/{occurrenceId}/status': post: tags: - Volunteer summary: 'Cancel or restore one occurrence without touching its schedule' description: 'Cancelling is not deleting: the row survives so a later generation run does not recreate it.' operationId: setVolunteerOccurrenceStatus parameters: - name: occurrenceId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - status properties: status: type: string enum: [scheduled, cancelled] type: object responses: '400': description: 'Unknown status' '401': description: 'Not authenticated' '403': description: 'Not authorized for this occurrence, or V2 is not enabled' '404': description: 'No such occurrence' '200': description: Updated security: - ApiKeyAuth: [] /ministries/event-series: get: tags: - Volunteer summary: 'The event titles a schedule may be narrowed to' description: 'The distinct titles of the active events of one event type (event_type mode) or owned by one ministry (ministry mode) from a date (default today) onward, with the next date and how many there are. The schedule dialog offers these so a schedule follows ONE series rather than every event of a type.' operationId: listVolunteerEventSeries parameters: - name: eventTypeId in: query description: 'One of eventTypeId and ministryId is required' required: false schema: type: integer - name: ministryId in: query required: false schema: type: integer - name: from in: query required: false schema: type: string format: date responses: '400': description: 'Neither eventTypeId nor ministryId given' '401': description: 'Not authenticated' '200': description: OK content: application/json: schema: properties: series: { type: array, items: { properties: { title: { type: string }, nextStart: { type: string }, count: { type: integer } }, type: object } } type: object security: - ApiKeyAuth: [] /ministries/event-types: get: tags: - Volunteer summary: 'The active calendar event types a schedule may follow' description: "Served on the ministries surface because a portal team leader (a self-service login) cannot reach /api/events/types. defaultEventTypeId is the type a ministry's new event starts with (D31): the one Admin → Ministry Settings names, else the type named Other, else null." operationId: listVolunteerEventTypes responses: '401': description: 'Not authenticated' '403': description: 'No ministry or team to manage, or V2 is not enabled' '200': description: OK content: application/json: schema: properties: eventTypes: { type: array, items: { properties: { id: { type: integer }, name: { type: string } }, type: object } } defaultEventTypeId: { type: integer, nullable: true } type: object security: - ApiKeyAuth: [] /ministries/classes: get: tags: - Volunteer summary: 'The groups a class schedule may follow' description: "Every active Sunday School class (group type 4), and any other group that has an upcoming active event linked to it, alphabetically, with how many upcoming events are linked and the next one's start." operationId: listVolunteerClasses parameters: - name: from in: query description: 'Counts events from this date; default today' required: false schema: type: string format: date responses: '401': description: 'Not authenticated' '200': description: OK content: application/json: schema: properties: classes: { type: array, items: { properties: { groupId: { type: integer }, name: { type: string }, sundaySchool: { type: boolean }, upcomingCount: { type: integer }, nextStart: { type: string, nullable: true } }, type: object } } type: object security: - ApiKeyAuth: [] /ministries/upcoming-events: get: tags: - Volunteer summary: 'Search upcoming calendar events to staff' description: "The Staff an event dialog's picker: active events from `from` (default today) up to `to`, whose title contains `q` (case-insensitive, % and _ literal), soonest first, at most 50. With `teamId`, each row says whether that team already has an occurrence anchored to the event." operationId: listVolunteerUpcomingEvents parameters: - name: q in: query required: false schema: type: string maxLength: 100 - name: from in: query required: false schema: type: string format: date - name: to in: query required: false schema: type: string format: date - name: teamId in: query required: false schema: type: integer responses: '400': description: 'A malformed date, or to before from' '401': description: 'Not authenticated' '403': description: 'teamId names a team the caller does not manage, or V2 is not enabled' '200': description: OK content: application/json: schema: properties: events: { type: array, items: { properties: { id: { type: integer }, title: { type: string }, start: { type: string }, end: { type: string }, eventTypeId: { type: integer }, eventTypeName: { type: string, nullable: true }, staffedByTeam: { type: boolean } }, type: object } } type: object security: - ApiKeyAuth: [] /ministries/dashboard: get: tags: - Volunteer summary: 'Everything the coordinator dashboard needs, in one call' description: 'The S1 aggregate (design section 5.2): upcoming occurrences, the gaps that need filling, assignments still awaiting a reply, proposed substitutions, and the number of notifications that failed terminally. Scoped to the caller in the query (section 4.4) — a global manager sees every ministry, a coordinator their ministries, a team leader their teams. The scope block additionally names the ministries and teams the caller may navigate to, which is what gives a team-scope-only user an entry point.' operationId: getVolunteerDashboard parameters: - name: days in: query description: 'Window length in days, 1-365, default 28' required: false schema: type: integer maximum: 365 minimum: 1 responses: '400': description: 'The days parameter is not a number in the supported range' '401': description: 'Not authenticated' '403': description: 'Not a volunteer coordinator, or V2 is not enabled' '200': description: OK content: application/json: schema: properties: days: { type: integer } from: { type: string, format: date } to: { type: string, format: date } upcoming: { type: array, items: { type: object } } gaps: { type: array, items: { type: object } } pendingResponses: { type: array, items: { type: object } } proposedSwaps: { type: array, items: { type: object } } failedNotifications: { type: integer } scope: { type: object } capped: { type: boolean } type: object security: - ApiKeyAuth: [] /ministries/me/assignments: get: tags: - Volunteer summary: 'My own volunteer commitments' description: 'The acting person comes from the session (AuthenticationManager::getCurrentUser()->getId(), which IS the person id). There is deliberately NO personId parameter - a personId in the query string is ignored, which is what makes substitution structurally impossible (design section 3.3.3). Upcoming only by default; includePast=1 adds what has already happened.' operationId: listMyVolunteerAssignments parameters: - name: from in: query required: false schema: type: string format: date - name: to in: query required: false schema: type: string format: date - name: includePast in: query required: false schema: type: boolean responses: '401': description: 'Not authenticated' '403': description: 'V2 is not enabled' '200': description: OK security: - ApiKeyAuth: [] '/ministries/me/assignments/{assignmentId}/respond': post: tags: - Volunteer summary: 'Accept or decline my own assignment' description: 'Idempotent (design section 2.12): responding with the status the assignment already has returns 200 with no second response row, so a double tap on a phone is harmless. A decline reopens the gap and enqueues a decline_alert for the coordinators (section 3.6) - which a coordinator-recorded decline on the other surface deliberately does not. Answering for someone else is 403, never 404.' operationId: respondToMyVolunteerAssignment parameters: - name: assignmentId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - response properties: response: type: string enum: [accepted, declined] comment: type: string nullable: true type: object responses: '400': description: 'Not a valid response' '401': description: 'Not authenticated' '403': description: 'Not my assignment, or V2 is not enabled' '404': description: 'No such assignment' '409': description: 'This assignment can no longer be answered' '200': description: OK security: - ApiKeyAuth: [] '/ministries/me/assignments/{assignmentId}/propose-substitute': post: tags: - Volunteer summary: 'Propose a substitute who has already agreed' description: 'The core of D13/UC2. personId here is the SUBSTITUTE, never the actor - the actor is the session. Eligibility is re-checked server-side whatever the UI offered: the substitute must hold an active qualification for the position (I2 applies to the replacement too) and must not already hold that position on that occurrence (I1). At most one proposal may be pending per assignment; a second is 409. The original assignment is not changed - only a substitute_proposed response row is appended.' operationId: proposeMyVolunteerSubstitute parameters: - name: assignmentId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - personId properties: personId: description: 'The substitute' type: integer comment: type: string nullable: true type: object responses: '400': description: 'Proposing yourself' '401': description: 'Not authenticated' '403': description: 'Not my assignment, the substitute is not qualified or already holds the position, or V2 is not enabled' '404': description: 'No such assignment or person' '409': description: 'A substitute is already proposed, or the assignment is no longer active' '201': description: Proposed security: - ApiKeyAuth: [] '/ministries/me/swaps/{swapId}/withdraw': post: tags: - Volunteer summary: 'Withdraw my own pending substitution request' description: "Proposer only (design section 2.13) - a coordinator approves or rejects, never withdraws on someone's behalf, so even a coordinator gets 403 here. Appends a substitute_withdrawn response row and changes nothing else: the original assignment stays exactly as it was. 409 unless the request is still proposed." operationId: withdrawMyVolunteerSwap parameters: - name: swapId in: path required: true schema: type: integer requestBody: required: false content: application/json: schema: properties: comment: type: string nullable: true type: object responses: '401': description: 'Not authenticated' '403': description: 'Not the proposer, or V2 is not enabled' '404': description: 'No such substitution request' '409': description: 'Already decided' '200': description: Withdrawn security: - ApiKeyAuth: [] /ministries/me/opportunities: get: tags: - Volunteer summary: 'Open slots I am qualified for and could sign up to right now' description: "Server-side eligibility, never the client's idea of it: the list is filtered to positions the SESSION person holds an active qualification for, on occurrences that are scheduled, not over, with capacity left. Pool membership is NOT tested - design D19 makes qualification the whole eligibility rule on this surface, and POST /me/signup applies exactly the same rule. Every row it returns is a row POST /me/signup would accept. There is no personId parameter (design section 3.3.3); one in the query string is ignored. Defaults to the next 90 days." operationId: listMyVolunteerOpportunities parameters: - name: from in: query required: false schema: type: string format: date - name: to in: query required: false schema: type: string format: date responses: '400': description: 'The window ends before it starts' '401': description: 'Not authenticated' '403': description: 'V2 is not enabled' '200': description: OK security: - ApiKeyAuth: [] /ministries/me/signup: post: tags: - Volunteer summary: 'Put myself on an open slot' description: 'Creates an assignment for the SESSION person with status accepted and source self_signup - a volunteer who volunteered has already answered. Qualification AND capacity are re-validated server-side at signup time whatever the list offered: 403 when unqualified (I2), 409 when the requirement is already at MaxCount, when the occurrence is cancelled or past (I5), or when the person already holds that position (I1). A personId in the body names nobody: the actor is the session.' operationId: signUpForMyVolunteerOpportunity requestBody: required: true content: application/json: schema: required: - occurrenceId - positionId properties: occurrenceId: type: integer positionId: type: integer type: object responses: '400': description: 'Missing or mismatched ids' '401': description: 'Not authenticated' '403': description: 'Not qualified, or V2 is not enabled' '404': description: 'No such occurrence or position' '409': description: 'Already full, already mine, cancelled or past' '201': description: 'Signed up' security: - ApiKeyAuth: [] /ministries/me/qualifications: get: tags: - Volunteer summary: 'What I am qualified to do' description: 'Read-only, and read-only on purpose: volunteers cannot grant themselves anything, so there is no write counterpart on this surface. Scoped to the SESSION person - a personId in the query string is ignored (design section 3.3.3).' operationId: listMyVolunteerQualifications responses: '401': description: 'Not authenticated' '403': description: 'V2 is not enabled' '200': description: OK security: - ApiKeyAuth: [] '/ministries/me/assignments/{assignmentId}/substitutes': get: tags: - Volunteer summary: 'Who I may offer as my substitute' description: "The picker behind section 5.6's Find a sub. Deliberately narrower than the coordinator's /eligible: it excludes the caller and anyone already holding this position on this occurrence - the exact two cases propose-substitute refuses - so the picker can never offer a name the server will then reject. 403, never 404, for someone else's assignment." operationId: listMyVolunteerSubstituteCandidates parameters: - name: assignmentId in: path required: true schema: type: integer - name: q in: query required: false schema: type: string responses: '401': description: 'Not authenticated' '403': description: 'Not my assignment, or V2 is not enabled' '404': description: 'No such assignment' '200': description: OK security: - ApiKeyAuth: [] /ministries/me/help-wanted: get: tags: - Volunteer summary: 'Ministries that are asking for help' description: "Every ACTIVE ministry that is advertising, alphabetically. A ministry advertises in either of two ways (design D19 as amended in round four): its own Help wanted switch is on, or it has at least one ACTIVE position with Recruit Volunteers on - either is enough. helpWantedText is the coordinator's own prose and may be empty; recruitingPositions names the positions being recruited for, ordered by team name and then by the position's own order, and is an empty array when there are none. Not filtered by qualification or by pool membership - the whole point of the section is to reach people the ministry does not know yet. inPool says whether the caller is already one of its volunteers, so the page can word the button's result correctly." operationId: listMyVolunteerHelpWanted responses: '401': description: 'Not authenticated' '403': description: 'V2 is not enabled' '200': description: OK content: application/json: schema: properties: ministries: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/ministries/me/help-wanted/{ministryId}': post: tags: - Volunteer summary: 'Tell a ministry you would like to help' description: "The acting person comes from the session; there is deliberately NO personId parameter (design section 3.3.3), so nobody can volunteer somebody else. Adds the caller to the ministry's volunteer pool Group if they are not already in it, then enqueues one help_offer message per coordinator per day - a second tap the same day sends nothing. Answers 403 only when the ministry is advertising in NEITHER way - no Help wanted switch and no active recruiting position - so a pool cannot be joined by guessing an id, while every ministry the help-wanted listing returns accepts the offer its rendered button makes." operationId: offerToHelpVolunteerMinistry parameters: - name: ministryId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'That ministry is not asking for help in either way, or V2 is not enabled' '404': description: 'No such ministry, or it has no pool group' '200': description: 'Thank you' content: application/json: schema: properties: joinedPool: { description: 'true when this tap is what put them in the pool', type: boolean } notified: { type: integer } type: object security: - ApiKeyAuth: [] '/ministries/occurrences/{occurrenceId}/staffing': get: tags: - Volunteer summary: 'Requirements, live/gap counts and assignment rows for one occurrence' description: 'The workhorse coordinator read (design section 5.5). Requirements come from VolunteerScheduleService::getEffectiveRequirements(); the counts come from VolunteerAssignmentService::getGaps(), the single gap implementation. Assignments for a position that has no requirement are returned under otherAssignments rather than being hidden.' operationId: getVolunteerOccurrenceStaffing parameters: - name: occurrenceId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this occurrence, or V2 is not enabled' '404': description: 'No such occurrence' '200': description: OK security: - ApiKeyAuth: [] '/ministries/occurrences/{occurrenceId}/eligible': get: tags: - Volunteer summary: 'Who may be assigned to a position on this occurrence' description: 'Actively qualified people, ordered by last served date ascending with never-served first - the rotation (design section 2.17). inPool reports pool membership rather than filtering on it, because an out-of-pool qualified person is assignable with the allowOutsidePool override. conflictPositionId names another position the person already holds on this occurrence (I7/D16) and is an annotation, never a filter.' operationId: listVolunteerEligiblePeople parameters: - name: occurrenceId in: path required: true schema: type: integer - name: positionId in: query required: true schema: type: integer - name: q in: query description: 'Case-insensitive name filter' required: false schema: type: string responses: '400': description: "positionId is missing or names a position outside this occurrence's ministry" '401': description: 'Not authenticated' '403': description: 'Not authorized for this occurrence, or V2 is not enabled' '404': description: 'No such occurrence' '200': description: OK security: - ApiKeyAuth: [] '/ministries/occurrences/{occurrenceId}/assignments': post: tags: - Volunteer summary: 'Assign a qualified person to a position on this occurrence' description: "Enforces design section 2.11.2: I4 the position belongs to this schedule's ministry (400), I5 the occurrence is neither cancelled nor past (409), I2 an active qualification (403), I3 membership of the ministry's volunteer pool Group unless allowOutsidePool is passed (409) - NOT applied to self sign-up, which tests qualification only (design D19), I1 one row per person per position per occurrence (409). I8 re-uses a declined or cancelled row rather than inserting a second one. I7/D16 - a person already holding ANOTHER position on this occurrence - is allowed unconditionally and returns 201." operationId: createVolunteerAssignment parameters: - name: occurrenceId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - positionId - personId properties: positionId: type: integer personId: type: integer requirementId: type: integer nullable: true allowOutsidePool: type: boolean notes: type: string nullable: true type: object responses: '400': description: "The position does not belong to this occurrence's schedule" '401': description: 'Not authenticated' '403': description: 'Not qualified, or not authorized, or V2 is not enabled' '404': description: 'No such occurrence, position or person' '409': description: 'Already assigned, outside the pool without the override, or the occurrence is cancelled or past' '201': description: Assigned security: - ApiKeyAuth: [] '/ministries/assignments/{assignmentId}': get: tags: - Volunteer summary: 'One assignment with its full response history' description: 'The history is returned because it is the only way to prove the idempotency rule of design section 2.12 from outside - accepting twice must leave exactly one response row (section 6.6). Swap rows are included for the same reason.' operationId: getVolunteerAssignment parameters: - name: assignmentId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this assignment, or V2 is not enabled' '404': description: 'No such assignment' '200': description: OK security: - ApiKeyAuth: [] delete: tags: - Volunteer summary: 'Cancel an assignment (hard-deletes only a pending row with no history)' description: 'Design section 3.3.2: cancel, never hard-delete once responded. A pending row that carries no response, no swap and is not itself a substitution is a mis-click with nothing to preserve and is removed outright along with its pending outbox rows; everything else is cancelled and kept, because the audit trail is what issue 9709 promises.' operationId: deleteVolunteerAssignment parameters: - name: assignmentId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this assignment, or V2 is not enabled' '404': description: 'No such assignment' '409': description: 'The occurrence has already happened' '200': description: 'Cancelled or deleted' security: - ApiKeyAuth: [] '/ministries/assignments/{assignmentId}/status': post: tags: - Volunteer summary: "Change an assignment's status, or record a response on the volunteer's behalf" description: "accepted and declined write a response row with channel 'coordinator' and the COORDINATOR as vrsp_per_ID, so the audit trail shows who actually recorded it (design section 2.11.1). A coordinator-recorded decline deliberately enqueues no decline_alert - the coordinators already know (section 3.6). cancelled is a coordinator act. An illegal transition is a 409 whose body carries currentStatus." operationId: setVolunteerAssignmentStatus parameters: - name: assignmentId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - status properties: status: type: string enum: [accepted, declined, cancelled] comment: type: string nullable: true type: object responses: '400': description: 'Not a status a coordinator may set directly' '401': description: 'Not authenticated' '403': description: 'Not authorized for this assignment, or V2 is not enabled' '404': description: 'No such assignment' '409': description: 'Illegal transition; the body carries currentStatus' '200': description: OK security: - ApiKeyAuth: [] '/ministries/assignments/{assignmentId}/notify': post: tags: - Volunteer summary: 'Re-enqueue the assignment message for this volunteer' description: "Idempotent through the dedupe key (design section 2.14): the row assign() already created is returned with created=false and nothing is duplicated. ?force=1 re-arms that SAME row to pending so the next drain sends it again - it never inserts a second one. Nothing is sent here; delivery is issue 9710's." operationId: notifyVolunteerAssignment parameters: - name: assignmentId in: path required: true schema: type: integer - name: force in: query required: false schema: type: boolean responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this assignment, or V2 is not enabled' '404': description: 'No such assignment' '200': description: OK security: - ApiKeyAuth: [] '/ministries/assignments/{assignmentId}/notifications': get: tags: - Volunteer summary: 'The notification outbox rows for one assignment' description: "Design section 6.6: the only way to prove from outside that a retried operation produced no second message. Newest first. Coordinator surface - the volunteer's own view of what they were sent is not this endpoint." operationId: listVolunteerAssignmentNotifications parameters: - name: assignmentId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this assignment, or V2 is not enabled' '404': description: 'No such assignment' '200': description: OK security: - ApiKeyAuth: [] /ministries/gaps: get: tags: - Volunteer summary: "Unfilled staffing requirements across the caller's scope" description: 'from and to are required, matching GET /ministries/occurrences. Every count comes from VolunteerAssignmentService::getGaps() - there is no gap table (design section 2.11.3).' operationId: listVolunteerGaps parameters: - name: from in: query required: true schema: type: string format: date - name: to in: query required: true schema: type: string format: date - name: ministryId in: query required: false schema: type: integer responses: '400': description: 'from or to missing or malformed' '401': description: 'Not authenticated' '403': description: 'Not a coordinator, or V2 is not enabled' '200': description: OK security: - ApiKeyAuth: [] /ministries/swaps: get: tags: - Volunteer summary: "The substitution queue for the caller's scope" operationId: listVolunteerSwaps parameters: - name: status in: query required: false schema: type: string enum: - proposed - approved - rejected - withdrawn - name: ministryId in: query required: false schema: type: integer - name: occurrenceId in: query required: false schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not a coordinator, or V2 is not enabled' '200': description: OK security: - ApiKeyAuth: [] '/ministries/swaps/{swapId}/approve': post: tags: - Volunteer summary: 'Approve a proposed substitution' description: "ONE transaction (design section 2.13): the original goes accepted to substituted, a new row is inserted for the substitute with source='substitute', status='accepted' and vasg_Replaces_vasg_ID pointing at the original, a response row is appended to both, and both parties get a swap_resolved outbox row. The original is never edited beyond its status, so the audit trail survives." operationId: approveVolunteerSwap parameters: - name: swapId in: path required: true schema: type: integer requestBody: required: false content: application/json: schema: properties: comment: type: string nullable: true type: object responses: '401': description: 'Not authenticated' '403': description: 'Not authorized, the substitute is no longer qualified, or V2 is not enabled' '404': description: 'No such substitution request' '409': description: 'Already decided, the occurrence is cancelled or past, or the substitute already holds the position' '200': description: Approved security: - ApiKeyAuth: [] '/ministries/swaps/{swapId}/reject': post: tags: - Volunteer summary: 'Reject a proposed substitution' description: 'The original assignment stays accepted and both parties are told (design section 2.13). The coordinator may then cancel it and assign someone else, which is the ordinary gap loop.' operationId: rejectVolunteerSwap parameters: - name: swapId in: path required: true schema: type: integer requestBody: required: false content: application/json: schema: properties: comment: type: string nullable: true type: object responses: '401': description: 'Not authenticated' '403': description: 'Not authorized, or V2 is not enabled' '404': description: 'No such substitution request' '409': description: 'Already decided' '200': description: Rejected security: - ApiKeyAuth: [] /ministries/scopes: get: tags: - Volunteer summary: 'List volunteer coordinator and team-leader scope grants' operationId: listVolunteerScopes parameters: - name: personId in: query description: 'Only grants held by this person' required: false schema: type: integer - name: ministryId in: query description: 'Only ministry-scope grants on this ministry' required: false schema: type: integer - name: teamId in: query description: 'Only team-scope grants on this team' required: false schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Ministry management access is required, or V2 is not enabled' '200': description: OK content: application/json: schema: properties: scopes: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] post: tags: - Volunteer summary: 'Grant ministry-coordinator or team-leader authority to a person' operationId: createVolunteerScope requestBody: required: true content: application/json: schema: required: - personId - scopeType - scopeId properties: personId: type: integer scopeType: type: string enum: [ministry, team] scopeId: description: 'Ministry id or team id' type: integer type: object responses: '400': description: 'Missing or invalid field' '401': description: 'Not authenticated' '403': description: "A ministry grant needs ministry management access; a team grant needs that or coordinating the team's ministry. Also when V2 is not enabled" '404': description: 'The person or the scope target does not exist' '200': description: "The grant already existed; the same row is returned. joinedPool says whether this call added the person to the ministry's volunteer pool (a grant always makes them a pool member; revoking never removes them)" '201': description: "Granted, and the person is in the ministry's volunteer pool" security: - ApiKeyAuth: [] '/ministries/scopes/{scopeId}': delete: tags: - Volunteer summary: 'Revoke a volunteer coordinator or team-leader scope grant' operationId: deleteVolunteerScope parameters: - name: scopeId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'The caller may not grant this scope (the same rule as granting it), or V2 is not enabled' '404': description: 'No such grant' '200': description: Revoked security: - ApiKeyAuth: [] /ministries/me/permissions: get: tags: - Volunteer summary: 'What volunteer structure may the authenticated person manage?' operationId: getMyVolunteerPermissions responses: '401': description: 'Not authenticated' '403': description: 'Volunteer Management V2 is not enabled' '200': description: OK content: application/json: schema: properties: isAdmin: { type: boolean } isGlobalManager: { type: boolean } isCoordinator: { type: boolean } isTeamLeader: { type: boolean } managedMinistryIds: { type: array, items: { type: integer } } managedTeamIds: { type: array, items: { type: integer } } type: object security: - ApiKeyAuth: [] /ministries/ministries: get: tags: - Volunteer summary: 'List the volunteer ministries the caller may administer' operationId: listVolunteerMinistries parameters: - name: active in: query description: 'Only active (1) or only inactive (0) ministries; omit for both' required: false schema: type: boolean responses: '401': description: 'Not authenticated' '403': description: 'Volunteer coordinator access is required, or V2 is not enabled' '200': description: OK content: application/json: schema: properties: ministries: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] post: tags: - Volunteer summary: 'Create a volunteer ministry' description: 'Global volunteer managers and administrators only (design §4.6).' operationId: createVolunteerMinistry requestBody: required: true content: application/json: schema: required: - name properties: name: type: string maxLength: 100 description: type: string maxLength: 255 sundaySchool: description: 'D29: the ministry may provide teachers for Sunday School (link teams to classes, schedule class meetings, give its events a class)' type: boolean default: false type: object responses: '400': description: 'The name is missing or empty, or sundaySchool is not a boolean' '401': description: 'Not authenticated' '403': description: 'Ministry management access is required, or V2 is not enabled' '409': description: 'A ministry with that name already exists' '201': description: Created security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}': get: tags: - Volunteer summary: 'One ministry with its teams and positions' operationId: getVolunteerMinistry parameters: - name: ministryId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry' '200': description: OK content: application/json: schema: properties: ministry: { type: object } summary: { description: "The three numbers the overview strip shows, computed with the caller's scope", properties: { teamCount: { type: integer }, volunteerCount: { description: "Members of the ministry's pool Group", type: integer }, unfilledPositionCount: { description: 'Open slots across every future scheduled occurrence the caller may see', type: integer } }, type: object } teams: { type: array, items: { type: object } } positions: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] post: tags: - Volunteer summary: "Update a ministry's name, description or active flag" operationId: updateVolunteerMinistry parameters: - name: ministryId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: name: type: string maxLength: 100 description: type: string maxLength: 255 active: type: boolean helpWanted: description: 'Advertise this ministry on the Open Opportunities page (design D19)' type: boolean helpWantedText: description: 'What the ministry wants to say there; rendered escaped with line breaks preserved' type: string sundaySchool: description: 'D29: the ministry may provide teachers for Sunday School. Only a global volunteer manager or an administrator may change it; a coordinator may send the current value' type: boolean type: object responses: '400': description: 'The name was sent empty, or sundaySchool is not a boolean' '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, a coordinator changing sundaySchool, or V2 is not enabled' '404': description: 'No such ministry' '409': description: 'A ministry with that name already exists, or sundaySchool was turned off while teams of the ministry are linked to a class (the message and `teams` name them)' '200': description: Updated security: - ApiKeyAuth: [] delete: tags: - Volunteer summary: 'Delete a deactivated ministry and everything under it' description: "Global volunteer managers and administrators only, and only for a ministry whose active flag is off - an active ministry is refused with 409 (deactivate it first, through POST with active:false). The delete then removes the ministry's teams, positions, qualifications, schedules, occurrences and assignments, service history included, together with its scope grants, pool Group and calendar (design §4.6, §5.4 as amended 2026-09-17)." operationId: deleteVolunteerMinistry parameters: - name: ministryId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Ministry management access is required, or V2 is not enabled' '404': description: 'No such ministry' '409': description: 'The ministry is still active' '200': description: Deleted security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/summary': get: tags: - Volunteer summary: 'The three counts the ministry overview shows' description: "teamCount, volunteerCount (members of the ministry's pool Group) and unfilledPositionCount - the sum of the open slots (required minus live, per effective requirement) across every future, scheduled occurrence the CALLER may see. A ministry coordinator, a global manager and an administrator are counted over the whole ministry; a team leader only over the occurrences whose schedule belongs to a team they lead, unioned across every such team. The same block is embedded in GET /ministries/{ministryId}, which is what the ministry page reads; this route exists for a caller who may see only part of the ministry and is therefore refused that one." operationId: getVolunteerMinistrySummary parameters: - name: ministryId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Neither a coordinator of this ministry nor a leader of a team in it, or V2 is not enabled' '404': description: 'No such ministry' '200': description: OK content: application/json: schema: properties: ministryId: { type: integer } summary: { properties: { teamCount: { type: integer }, volunteerCount: { type: integer }, unfilledPositionCount: { type: integer }, occurrenceCount: { description: 'All-time, unscoped - for the Delete confirmation', type: integer }, assignmentCount: { description: 'All-time, unscoped - for the Delete confirmation', type: integer } }, type: object } type: object security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/teams': get: tags: - Volunteer summary: 'Teams inside a ministry' operationId: listVolunteerTeams parameters: - name: ministryId in: path required: true schema: type: integer - name: active in: query required: false schema: type: boolean responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry' '200': description: OK content: application/json: schema: properties: teams: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] post: tags: - Volunteer summary: 'Create a team inside a ministry' operationId: createVolunteerTeam parameters: - name: ministryId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - name properties: name: type: string maxLength: 100 description: type: string maxLength: 255 classGroupId: description: "D23: link the team to this Sunday School class (group type 4); its Teacher role is then written from the team's qualifications" type: integer nullable: true importPositionId: description: "D23: the position of this team the class's current teachers are qualified for when it is linked" type: integer nullable: true importPositionName: description: 'D23: instead of importPositionId, create a position of this name in the team and qualify the teachers for it' type: string nullable: true type: object responses: '400': description: 'The name is missing or empty; the ministry does not provide teachers for Sunday School (D29); the class is not a Sunday School class or has no Teacher role; or the class has teachers and no position to import them into was given' '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry' '409': description: 'A team with that name already exists in this ministry, or the class is linked to another team' '201': description: Created security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/linkable-classes': get: tags: - Volunteer summary: 'Sunday School classes a team of this ministry may be linked to (D23)' description: 'Every group of type 4 not linked to another team, with how many living members hold its Teacher role - the qualifications linking it would import. With teamId, the class that team is linked to is included. Empty, with sundaySchool false, for a ministry that does not provide teachers for Sunday School (D29).' operationId: listVolunteerLinkableClasses parameters: - name: ministryId in: path required: true schema: type: integer - name: teamId in: query required: false schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry' '200': description: OK content: application/json: schema: properties: classes: { type: array, items: { properties: { id: { type: integer }, name: { type: string }, teacherCount: { type: integer } }, type: object } } type: object security: - ApiKeyAuth: [] '/ministries/teams/{teamId}': get: tags: - Volunteer summary: 'One team with the positions scoped to it' operationId: getVolunteerTeam parameters: - name: teamId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this team, or V2 is not enabled' '404': description: 'No such team' '200': description: OK content: application/json: schema: properties: team: { type: object } positions: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] post: tags: - Volunteer summary: "Update a team's name, description or active flag" operationId: updateVolunteerTeam parameters: - name: teamId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: name: type: string maxLength: 100 description: type: string maxLength: 255 active: type: boolean classGroupId: description: "D23: link to this Sunday School class, or null to unlink (the class's membership is left as it is). Absent = unchanged" type: integer nullable: true importPositionId: description: 'D23: when linking a class that has teachers, the position of this team they are qualified for' type: integer nullable: true importPositionName: description: 'D23: instead of importPositionId, create a position of this name in the team and qualify the teachers for it' type: string nullable: true classEvents: description: 'D28: when classGroupId changes, what happens to the events this ministry owns whose Linked Group is the old class - kept, the class removed from them, or moved to the new class (move needs a new class). GET /ministries/teams/{teamId}/class-events counts them' type: string default: keep enum: [keep, remove, move] type: object responses: '400': description: 'The name was sent empty; the ministry does not provide teachers for Sunday School (D29); the class is not a Sunday School class or has no Teacher role; the class has teachers and no position of this team to import them into was given; or classEvents is unknown, or move without a new class' '401': description: 'Not authenticated' '403': description: "Not authorized for this team (a team leader may not edit it) or for one of the class's events, or V2 is not enabled" '404': description: 'No such team' '409': description: 'A team with that name already exists in this ministry, or the class is linked to another team' '200': description: Updated security: - ApiKeyAuth: [] delete: tags: - Volunteer summary: 'Delete a team and everything under it' description: "Removes the team's positions with their qualifications and staffing requirements, its schedules and occurrences, every assignment on them (service history included) and its team-leader grants, in one transaction. Deactivate the team instead to keep its history. Returns 409 when this is the ministry's ONLY team - a ministry always has at least one, so the answer is to rename it. For a team linked to a class, classEvents (D28) says what happens to the events this ministry owns whose Linked Group is that class: kept, the class removed from them, or deleted through core's event delete in the same transaction." operationId: deleteVolunteerTeam parameters: - name: teamId in: path required: true schema: type: integer requestBody: required: false content: application/json: schema: properties: classEvents: type: string default: keep enum: [keep, remove, delete] type: object responses: '400': description: 'classEvents is unknown' '401': description: 'Not authenticated' '403': description: "Not authorized for this team or for one of the class's events, or V2 is not enabled" '404': description: 'No such team' '409': description: "This is the ministry's only team; or classEvents delete while another ministry has volunteers assigned on one of the events (unless the caller holds Add Events), or core refuses to delete one (people checked in, a kiosk) - nothing is deleted" '200': description: Deleted security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/positions': get: tags: - Volunteer summary: 'Positions of a ministry' description: 'A ministry coordinator sees every position; a team leader sees the positions of the teams they lead (design §4.4 - the narrowing happens in the query).' operationId: listVolunteerPositions parameters: - name: ministryId in: path required: true schema: type: integer - name: teamId in: query required: false schema: type: integer - name: active in: query required: false schema: type: boolean responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry' '200': description: OK content: application/json: schema: properties: positions: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/ministries/positions/{positionId}': get: tags: - Volunteer summary: 'One position' operationId: getVolunteerPosition parameters: - name: positionId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this position, or V2 is not enabled' '404': description: 'No such position' '200': description: OK content: application/json: schema: properties: position: { type: object } type: object security: - ApiKeyAuth: [] delete: tags: - Volunteer summary: 'Delete a position and everything that references it' description: "Removes the position's qualifications (revoked ones included), staffing requirements and assignments, service history included, in one transaction. Deactivate the position instead to keep its history (design §2.6). On a team linked to a Sunday School class, anybody left with no active qualification in the team leaves the class's Teacher role (D23)." operationId: deleteVolunteerPosition parameters: - name: positionId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this position, or V2 is not enabled' '404': description: 'No such position' '200': description: Deleted security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/pool': get: tags: - Volunteer summary: "The people in this ministry's volunteer pool" description: "The membership of the ministry's own Group (group_grp.grp_ministry_id), read live from person2group2role_p2g2r - V2 stores no people (design D1, D19). Alphabetical." operationId: listVolunteerPoolMembers parameters: - name: ministryId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry' '200': description: OK content: application/json: schema: properties: groupId: { type: integer, nullable: true } groupName: { type: string, nullable: true } members: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/pool/{personId}': post: tags: - Volunteer summary: "Put a person in this ministry's volunteer pool" description: "Writes a person2group2role_p2g2r row on the ministry's own Group with the group's default role, firing the same GROUP_MEMBER_ADDED plugin hook the Groups module fires. A ministry coordinator may do this WITHOUT the global Manage Groups permission (design D19) - the Propel hooks allow it because the group carries this ministry's id. Idempotent: somebody already in the pool is 200, not 409." operationId: addVolunteerPoolMember parameters: - name: ministryId in: path required: true schema: type: integer - name: personId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry, person, or pool group' '200': description: 'They were already in the pool' '201': description: Added security: - ApiKeyAuth: [] delete: tags: - Volunteer summary: "Take a person out of this ministry's volunteer pool" description: 'Deletes the membership row and fires GROUP_MEMBER_REMOVED. Their qualifications are NOT revoked (design D19): the two are independent, and a qualified non-member is still assignable.' operationId: removeVolunteerPoolMember parameters: - name: ministryId in: path required: true schema: type: integer - name: personId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry, or that person is not in the pool' '200': description: Removed security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/pool/from-cart': post: tags: - Volunteer summary: "Put everyone in the session cart into this ministry's volunteer pool" description: 'The V2 Cart sink for the pool (design P5/P6, D19): the route reads Cart::getCartPeople() and hands the ids to VolunteerMinistryService::addPoolMembers(), which writes one person2group2role_p2g2r row per person through the managed-write context so the GROUP_MEMBER_ADDED plugin hook fires exactly as it does for the single-person route. Idempotent per person: somebody already in the pool is counted in alreadyMembers rather than failing the batch, so pressing the button twice is safe. No qualification is granted - being in the pool is candidacy, the tick on the grid is eligibility (design section 2.5). The cart is NOT emptied. Takes no request body.' operationId: addVolunteerPoolMembersFromCart parameters: - name: ministryId in: path required: true schema: type: integer responses: '400': description: 'The cart is empty' '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry, or it has no pool group' '200': description: OK content: application/json: schema: properties: added: { description: 'How many people were newly put in the pool', type: integer } alreadyMembers: { description: 'How many were in it already', type: integer } type: object security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/volunteers/{personId}': delete: tags: - Volunteer summary: 'Take one person out of a ministry entirely' description: "In one transaction: revokes every active qualification they hold for a position of this ministry (revocation is deactivation, design section 2.7), cancels every live assignment of theirs on a still-to-come occurrence of the ministry through the ordinary cancel path so the outbox rows are cancelled and the response trail is appended, removes them from the ministry's pool Group through the managed-write context, and clears every schedule default of this ministry that names them (D32; revoking one qualification keeps the default). Past assignments are service history and are left alone. Ministry-level authority: a team leader gets 403." operationId: removeVolunteerFromMinistry parameters: - name: ministryId in: path required: true schema: type: integer - name: personId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry or person' '200': description: Removed content: application/json: schema: properties: personId: { type: integer } qualifications: { description: 'Qualifications revoked', type: integer } assignments: { description: 'Upcoming assignments cancelled', type: integer } removedFromPool: { type: boolean } defaults: { description: 'Schedule default volunteer entries cleared', type: integer } type: object security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/members': get: tags: - Volunteer summary: "The rows of this ministry's qualification matrix" description: "Everyone in the ministry's pool Group UNION everyone actively qualified for one of the positions in view (design D19). Each row carries inPool and the position ids that person is qualified for, so the matrix renders from one response (section 5.4)." operationId: listVolunteerMatrixMembers parameters: - name: ministryId in: path required: true schema: type: integer - name: teamId in: query required: false schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry' '200': description: OK content: application/json: schema: properties: members: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/ministries/teams/{teamId}/members': get: tags: - Volunteer summary: "The people in one team's volunteer pools" description: "The team's own pools UNION the parent ministry's pools, because a ministry-wide pool feeds every team under it." operationId: listVolunteerTeamMembers parameters: - name: teamId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this team, or V2 is not enabled' '404': description: 'No such team' '200': description: OK content: application/json: schema: properties: members: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/ministries/ministries/{ministryId}/qualification-matrix': get: tags: - Volunteer summary: 'Everything the qualification matrix needs, in one response' description: "Pool members UNION everyone qualified for a position in view down the side (design D19), active positions across the top, and each person's qualified position ids - section 5.4 requires the matrix to handle 15-200 people without re-fetching per cell. A row's inPool flag says which half it is there for." operationId: getVolunteerQualificationMatrix parameters: - name: ministryId in: path required: true schema: type: integer - name: teamId in: query description: 'Narrow both the columns and the pool people to one team' required: false schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this ministry, or V2 is not enabled' '404': description: 'No such ministry' '200': description: OK content: application/json: schema: properties: ministryId: { type: integer } teamId: { type: integer, nullable: true } positions: { type: array, items: { type: object } } people: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/ministries/teams/{teamId}/qualification-matrix': get: tags: - Volunteer summary: "One team's qualification matrix, in one response" description: "The team-scoped twin of the ministry route: the same document, narrowed to one team, gated per team rather than per ministry so a team leader can open their own grid (design section 4.4). Added for the Member Portal's My Teams page (#9868)." operationId: getVolunteerTeamQualificationMatrix parameters: - name: teamId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this team, or V2 is not enabled' '404': description: 'No such team' '200': description: OK content: application/json: schema: properties: ministryId: { type: integer } teamId: { type: integer } positions: { type: array, items: { type: object } } people: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/ministries/positions/{positionId}/qualifications': get: tags: - Volunteer summary: 'Who is qualified for a position' description: 'Includes revoked rows by default: revocation is deactivation, so the grant history stays readable (design section 2.7). Pass active=1 for the eligibility list.' operationId: listVolunteerQualifications parameters: - name: positionId in: path required: true schema: type: integer - name: active in: query required: false schema: type: boolean responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this position, or V2 is not enabled' '404': description: 'No such position' '200': description: OK content: application/json: schema: properties: qualifications: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] post: tags: - Volunteer summary: 'Qualify a person for a position' description: 'Idempotent by UNIQUE (vqal_per_ID, vqal_vpos_ID): 201 when the row is new, 200 when it already existed - a re-grant reactivates a revoked row rather than inserting a second. The person does NOT have to be in a linked pool: the pool is the candidate set, the qualification is the eligibility (design section 2.5).' operationId: grantVolunteerQualification parameters: - name: positionId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - personId properties: personId: type: integer notes: type: string maxLength: 255 type: object responses: '400': description: 'personId is missing or not an integer' '401': description: 'Not authenticated' '403': description: 'Not authorized for this position, or V2 is not enabled' '404': description: 'No such position or person' '409': description: "The position's team is linked to a Sunday School class and the person is in that class under another role, a Student above all (D23). A grant on a linked team otherwise also gives the class's Teacher role" '200': description: 'The qualification already existed and is active' '201': description: Granted security: - ApiKeyAuth: [] '/ministries/qualifications/{qualificationId}': delete: tags: - Volunteer summary: 'Revoke a qualification by deactivating it' description: "The row is KEPT with active=false (design section 2.7). Historical assignments stay valid because volunteer_assignment_vasg has no foreign key to a qualification - it references the person and the position directly. On a team linked to a Sunday School class, revoking the person's last active qualification in the team also ends their Teacher-role membership of the class (D23)." operationId: revokeVolunteerQualification parameters: - name: qualificationId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Not authorized for this qualification, or V2 is not enabled' '404': description: 'No such qualification' '200': description: 'Revoked; the row is returned with active=false' security: - ApiKeyAuth: [] '/ministries/people/{personId}/qualifications': get: tags: - Volunteer summary: 'Everything one person is qualified for, scoped to the caller' description: 'A global manager sees every ministry; a coordinator sees only their own, and the narrowing happens in the query (design section 4.4).' operationId: listVolunteerQualificationsForPerson parameters: - name: personId in: path required: true schema: type: integer responses: '401': description: 'Not authenticated' '403': description: 'Volunteer coordinator access is required, or V2 is not enabled' '200': description: OK content: application/json: schema: properties: qualifications: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] /ministries/status: get: tags: - Volunteer summary: 'Report the Volunteer Management rollout state' responses: '403': description: 'Volunteer Management V2 is not enabled' '200': description: OK content: application/json: schema: properties: version: { type: string, enum: [v1, v2, both] } v1Enabled: { type: boolean } v2Enabled: { type: boolean } type: object security: - ApiKeyAuth: [] /email/log: get: tags: - Email summary: 'Page through the email history' description: 'Newest first. With personId or familyId the caller must be allowed to view that record; without either the caller must be an administrator (recent sends across everyone).' parameters: - name: personId in: query schema: type: integer - name: familyId in: query description: 'Rows for the family address and its current members' schema: type: integer - name: status in: query description: 'Only honoured on the unscoped (admin) list' schema: type: string enum: - sent - failed - skipped - name: page in: query schema: type: integer default: 1 - name: limit in: query schema: type: integer default: 25 maximum: 100 responses: '200': description: 'A page of history rows' content: application/json: schema: properties: rows: { type: array, items: { properties: { id: { type: integer }, personId: { type: integer, nullable: true }, familyId: { type: integer, nullable: true }, sentByUserId: { type: integer, nullable: true }, sentBy: { description: 'Name of the user who wrote a composer message', type: string, nullable: true }, address: { type: string }, kind: { type: string }, kindLabel: { type: string }, subject: { type: string }, status: { type: string, enum: [sent, failed, skipped] }, error: { type: string, nullable: true }, dateSent: { type: string }, hasBody: { type: boolean } }, type: object } } total: { type: integer } page: { type: integer } limit: { type: integer } pages: { type: integer } type: object '401': description: Unauthorized '403': description: "Not allowed to view this record's history" security: - ApiKeyAuth: [] '/email/log/{id}': get: tags: - Email summary: 'One email history row with its stored body' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'The row; body is null when the email class does not store bodies (account emails)' '401': description: Unauthorized '403': description: "Not allowed to view this record's history" '404': description: 'No such row' security: - ApiKeyAuth: [] /map/families: get: tags: - Map summary: 'Get geocoded map items — families, group members, or cart persons' parameters: - name: groupId in: query description: 'When omitted or null: all active geocoded families. When 0: persons in the session cart. When > 0: members of that group.' required: false schema: type: integer responses: '200': description: 'Array of map items' content: application/json: schema: type: array items: properties: { id: { type: integer }, type: { type: string, enum: [family, person] }, name: { type: string }, salutation: { type: string }, address: { type: string }, latitude: { type: number, format: float }, longitude: { type: number, format: float }, classificationId: { type: integer }, profileUrl: { type: string }, directionsUrl: { type: string }, phone: { type: string } } type: object security: - ApiKeyAuth: [] '/map/neighbors/{familyId}': get: tags: - Map summary: 'Get nearest neighbor families for a given familyId' parameters: - name: familyId in: path required: true schema: type: integer - name: maxNeighbors in: query required: false schema: type: integer - name: maxDistance in: query required: false schema: type: number format: float - name: classificationIds in: query description: 'Comma-separated classification option IDs to filter listed people by' required: false schema: type: string responses: '200': description: 'Array of neighbor families' security: - ApiKeyAuth: [] /portal/me/emails: get: tags: - 'Member Portal' summary: "A page of the signed-in member's own email history" description: 'Newest first, in the same envelope GET /email/log answers with, but scoped to the acting member: only rows whose eml_per_ID is this person. There is no person id to pass. Rows never carry the body; GET /portal/me/emails/{id} does. Session only — API keys are refused.' operationId: getPortalMyEmails parameters: - name: page in: query schema: type: integer default: 1 - name: limit in: query schema: type: integer default: 25 maximum: 100 responses: '200': description: "A page of the member's history" content: application/json: schema: properties: rows: { type: array, items: { properties: { id: { type: integer }, personId: { type: integer, nullable: true }, address: { type: string }, kind: { type: string }, kindLabel: { type: string }, subject: { type: string }, status: { type: string, enum: [sent, failed, skipped] }, dateSent: { type: string }, hasBody: { type: boolean } }, type: object } } total: { type: integer } page: { type: integer } limit: { type: integer } pages: { type: integer } type: object '401': description: 'No signed-in session' '403': description: 'API-key caller, or an account with no person record' '/portal/me/emails/{id}': get: tags: - 'Member Portal' summary: "One email from the signed-in member's own history, with its body" description: "The row is returned only when it was addressed to the acting member. Somebody else's row and an id that does not exist are the same 404, so a member cannot learn that an email exists by asking for it. The body is null for the email classes that never store one (account emails carrying a password or a one-time link)." operationId: getPortalMyEmail parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'The row, with its stored body' '401': description: 'No signed-in session' '403': description: 'API-key caller, or an account with no person record' '404': description: "No such email in this member's history" /portal/calendar/subscription: get: tags: - 'Member Portal' summary: "The signed-in member's calendar subscription" description: 'Returns the calendars the church shares with members, which of them this member has subscribed to, and the address their calendar app should use. `url` and `webcalUrl` are null until the member saves a selection for the first time — the token is minted then. Session only; API keys are refused.' operationId: getPortalCalendarSubscription responses: '200': description: "The member's subscription" content: application/json: schema: $ref: '#/components/schemas/PortalCalendarSubscription' '403': description: 'Not a signed-in member, or an API key was used' put: tags: - 'Member Portal' summary: "Choose which shared calendars the member's feed carries" description: "Stores the member's selection and mints their feed token the first time. Every id must name a calendar the administrator currently shares with members; anything else is a 400, with the same message whether the calendar does not exist or is simply not shared. At least one calendar is required — a feed of nothing is not a useful thing to hand a calendar app." operationId: putPortalCalendarSubscription requestBody: required: true content: application/json: schema: properties: calendars: description: 'Calendar ids, as GET returns them' type: array items: { type: string, example: 'calendar:3' } type: object responses: '200': description: 'The saved subscription' content: application/json: schema: $ref: '#/components/schemas/PortalCalendarSubscription' '400': description: 'Empty selection, or a calendar that is not shared with members' '403': description: 'Not a signed-in member, an API key was used, or the CSRF token was missing' /portal/calendar/subscription/reset: post: tags: - 'Member Portal' summary: 'Issue the member a new feed address' description: "Rotates the member's feed token, keeping their calendar selection. The previous address stops answering immediately — this is what a member does when they have shared the old one by accident." operationId: resetPortalCalendarSubscription responses: '200': description: 'The subscription, at its new address' content: application/json: schema: $ref: '#/components/schemas/PortalCalendarSubscription' '403': description: 'Not a signed-in member, an API key was used, or the CSRF token was missing' /portal/me: get: tags: - 'Member Portal' summary: "The signed-in member's own profile" description: "Returns the acting member's person record as the Member Portal renders it. The member is taken from the session; there is no person id to pass. Session only — API keys are refused." operationId: getPortalMe responses: '200': description: "The member's profile" content: application/json: schema: properties: profile: { properties: { id: { type: integer, example: 100 }, firstName: { type: string, example: Lena }, lastName: { type: string, example: Black }, email: { type: string, example: lena@example.com }, cellPhone: { type: string, example: '(206) 555-0100' }, birthday: { description: 'ISO date, empty when unknown', type: string, example: '1971-12-24' }, photoUrl: { type: string }, canEditBirthday: { description: 'Mirrors bPortalAllowBirthdayEdit', type: boolean } }, type: object } type: object '401': description: 'No signed-in session' '403': description: 'API-key caller, or an account with no person record' post: tags: - 'Member Portal' summary: "Update the signed-in member's own profile" description: "Applies the allow-listed fields present in the body to the member's own person record and writes a timeline note naming what changed. Anything else in the body is ignored. Birthday is accepted only when bPortalAllowBirthdayEdit is on." operationId: updatePortalMe requestBody: required: true content: application/json: schema: properties: firstName: type: string middleName: type: string lastName: type: string title: type: string suffix: type: string email: type: string workEmail: type: string homePhone: type: string cellPhone: type: string workPhone: type: string birthday: type: string example: '1971-12-24' type: object responses: '200': description: 'The saved profile' content: application/json: schema: properties: success: { type: boolean, example: true } profile: { type: object } updated: { description: 'The fields that actually changed', type: array, items: { type: string } } type: object '400': description: 'Validation error' '403': description: 'API-key caller, or an invalid CSRF token' /portal/me/photo: get: tags: - 'Member Portal' summary: "The signed-in member's own photo (binary image)" description: "Returns the member's uploaded photo bytes. The portal serves its own copy because AuthMiddleware confines a self-service session to /portal and /api/portal, so /api/person/{id}/photo — which the profile used to point at — answers those sessions with 403 and every avatar renders broken. The member is taken from the session; there is no person id to pass. Cached privately for two hours, the same window the person endpoint uses; the URLs the portal hands out carry a ?v= cache-buster so a fresh upload is visible at once." operationId: getPortalMyPhoto responses: '200': description: 'The photo' content: 'image/*': schema: type: string format: binary '401': description: 'No signed-in session' '403': description: 'API-key caller, or an account with no person record' '404': description: 'This member has not uploaded a photo' post: tags: - 'Member Portal' summary: "Replace the signed-in member's own photo" description: "The /api/person/{id}/photo upload route is behind the EditRecords role, which a self-service login never has, so the portal has its own endpoint for the member's own photo. It calls the same model method and writes the same 'photo' timeline note." operationId: updatePortalMyPhoto requestBody: required: true content: application/json: schema: required: - imgBase64 properties: imgBase64: description: 'Base64-encoded image with its data URI prefix' type: string type: object responses: '200': description: 'Photo stored' content: application/json: schema: properties: success: { type: boolean, example: true } profile: { type: object } type: object '400': description: 'Missing or unusable image data' '413': description: 'Image larger than the server allows' /portal/family: get: tags: - 'Member Portal' summary: "The signed-in member's own family" description: "Returns the acting member's family, its members, and whether this member is one of the adults who may change it. The family is the member's own; there is no family id to pass." operationId: getPortalFamily responses: '200': description: "The member's family" content: application/json: schema: properties: family: { type: object, nullable: true } members: { type: array, items: { type: object } } canEdit: { description: "True for the family's head or spouse", type: boolean } type: object '403': description: 'API-key caller' post: tags: - 'Member Portal' summary: "Update the signed-in member's own family" description: "Applies the allow-listed fields present in the body to the member's own family and writes a timeline note naming what changed. Only an adult of the family (head or spouse, per sDirRoleHead / sDirRoleSpouse) may call it; every other member gets 403." operationId: updatePortalFamily requestBody: required: true content: application/json: schema: properties: address1: type: string address2: type: string city: type: string state: type: string zip: type: string country: type: string homePhone: type: string email: type: string weddingDate: type: string example: '2004-06-12' type: object responses: '200': description: 'The saved family' content: application/json: schema: properties: success: { type: boolean, example: true } family: { type: object } updated: { type: array, items: { type: string } } type: object '400': description: 'Validation error' '403': description: 'Not an adult of this family, an API-key caller, or an invalid CSRF token' '404': description: 'The member has no family record' /portal/family/confirm: post: tags: - 'Member Portal' summary: "Confirm the member's family details are current" description: "Writes exactly the note the emailed verify link writes — type 'verify', entered by SELF_VERIFY, the text 'No Changes' or the member's comment — so a portal confirmation reaches the People → Verify dashboard the same way a token-link one does." operationId: confirmPortalFamily requestBody: required: true content: application/json: schema: required: - result properties: result: type: string enum: [no-change, change-needed] comment: description: 'Required when result is change-needed' type: string type: object responses: '200': description: 'Confirmation recorded' content: application/json: schema: properties: success: { type: boolean, example: true } type: object '400': description: 'No choice made, or a change requested with no comment' '404': description: 'The member has no family record' '/portal/family/members/{personId}/photo': get: tags: - 'Member Portal' summary: "The photo of somebody in the signed-in member's own family (binary image)" description: "The read side of the portal's own photo serving, for the faces on the My Family page. The id is checked against the members of the acting member's family — the same enumeration GET /portal/family renders — and anything else is 404, never 403: a 403 would confirm that the id names a real person, which a member outside that family must not learn (design P12). This is the only portal route that takes a person id, and it never names the actor." operationId: getPortalFamilyMemberPhoto parameters: - name: personId in: path description: "A person in the acting member's own family" required: true schema: type: integer responses: '200': description: 'The photo' content: 'image/*': schema: type: string format: binary '401': description: 'No signed-in session' '403': description: 'API-key caller, or an account with no person record' '404': description: 'Not a member of this family, or that member has no photo' /portal/family/members: post: tags: - 'Member Portal' summary: "Propose a new member of the signed-in member's family" description: "Creates a person in the member's family marked SELF_REGISTER, exactly as the public registration form does, so the entry waits on People → Self Registrations for staff to review. It is never a live member. Only an adult of the family may call it." operationId: addPortalFamilyMember requestBody: required: true content: application/json: schema: required: - firstName - lastName properties: firstName: type: string lastName: type: string role: description: 'A family-role option id; falls back to the configured child role' type: string birthday: type: string example: '2015-03-08' type: object responses: '200': description: 'Pending entry created' content: application/json: schema: properties: success: { type: boolean, example: true } personId: { type: integer, example: 1234 } type: object '400': description: 'Validation error' '403': description: 'Not an adult of this family' '404': description: 'The member has no family record' /issues: post: tags: - System summary: 'Generate a GitHub issue body pre-filled with system diagnostics' requestBody: required: true content: application/json: schema: properties: pageName: type: string screenSize: properties: { height: { type: integer }, width: { type: integer } } type: object windowSize: properties: { height: { type: integer }, width: { type: integer } } type: object pageSize: properties: { height: { type: integer }, width: { type: integer } } type: object browserLocale: type: string browserTimezone: type: string type: object responses: '200': description: 'Pre-formatted issue body string for GitHub' content: application/json: schema: properties: issueBody: { type: string } type: object security: - ApiKeyAuth: [] /property-types: get: tags: - System summary: 'List all property types (MenuOptions role required)' parameters: - name: class in: query description: 'Filter by class (p/f/g)' required: false schema: type: string responses: '200': description: 'Array of property types' '401': description: Unauthorized '403': description: 'MenuOptions role required' security: - ApiKeyAuth: [] post: tags: - System summary: 'Create a new property type (MenuOptions role required)' requestBody: required: true content: application/json: schema: required: - class - name properties: class: type: string enum: [p, f, g] name: type: string maxLength: 50 description: type: string type: object responses: '201': description: 'Newly created property type' '400': description: 'Validation error' '401': description: Unauthorized '403': description: 'MenuOptions role required' security: - ApiKeyAuth: [] '/property-types/{id}': get: tags: - System summary: 'Get a single property type (MenuOptions role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Property type object' '404': description: 'Property type not found' '401': description: Unauthorized '403': description: 'MenuOptions role required' security: - ApiKeyAuth: [] put: tags: - System summary: 'Update a property type (MenuOptions role required)' parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: class: type: string enum: [p, f, g] name: type: string maxLength: 50 description: type: string type: object responses: '200': description: 'Updated property type' '400': description: 'Validation error' '404': description: 'Property type not found' '401': description: Unauthorized '403': description: 'MenuOptions role required' security: - ApiKeyAuth: [] delete: tags: - System summary: 'Delete a property type (MenuOptions role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Deleted '404': description: 'Property type not found' '409': description: 'Property type is still in use by one or more properties' '401': description: Unauthorized '403': description: 'MenuOptions role required' security: - ApiKeyAuth: [] '/family/{familyId}/photo': get: tags: - Families summary: 'Get uploaded photo for a family (binary image)' parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Binary image data' '404': description: 'No uploaded photo exists for this family or family not found' security: - ApiKeyAuth: [] post: tags: - Families summary: 'Upload a family photo from base64 data (EditRecords role required)' parameters: - name: familyId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: imgBase64: description: 'Base64-encoded image data' type: string type: object responses: '200': description: 'Photo uploaded successfully' content: application/json: schema: properties: success: { type: boolean } hasPhoto: { type: boolean } type: object '400': description: 'Failed to upload photo' '403': description: 'EditRecords role required' '413': description: 'PHP discarded the request body because it exceeded the server upload limit' security: - ApiKeyAuth: [] delete: tags: - Families summary: "Delete a family's uploaded photo (EditRecords role required)" parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Photo deletion result' content: application/json: schema: properties: success: { type: boolean } type: object '403': description: 'EditRecords role required' security: - ApiKeyAuth: [] '/family/{familyId}/avatar': get: tags: - Families summary: 'Get avatar info JSON for a family (for client-side rendering)' parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Avatar info object (type, url, initials, color, etc.)' '404': description: 'Family not found' security: - ApiKeyAuth: [] '/family/{familyId}/nav': get: tags: - Families summary: 'Get previous and next family IDs for navigation' parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Navigation IDs' content: application/json: schema: properties: PreFamilyId: { type: integer } NextFamilyId: { type: integer } type: object security: - ApiKeyAuth: [] '/family/{familyId}': get: tags: - Families summary: 'Get a family object by ID' parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: "Family object. `Address` is the primary/physical address.\n * The optional second address (#9743) is exposed additively as `SecondAddress1`, `SecondAddress2`,\n * `SecondCity`, `SecondState`, `SecondZip`, `SecondCountry` and `SecondIsMailing`, alongside the\n * resolved `MailingAddress` object (the flagged second address when set, the primary otherwise)." content: application/json: schema: properties: Id: { type: integer } Name: { type: string } Address: { description: 'Primary address, one line', type: string } SecondAddress1: { type: string, nullable: true } SecondAddress2: { type: string, nullable: true } SecondCity: { type: string, nullable: true } SecondState: { type: string, nullable: true } SecondZip: { type: string, nullable: true } SecondCountry: { type: string, nullable: true } SecondIsMailing: { type: boolean } HasSecondAddress: { type: boolean } SecondAddressIsMailing: { type: boolean } SecondAddress: { description: 'Second address, one line', type: string } MailingAddressLines: { description: 'Resolved mailing address, one line', type: string } MailingAddress: { description: 'Resolved mailing address parts', properties: { Address1: { type: string }, Address2: { type: string }, City: { type: string }, State: { type: string }, Zip: { type: string }, Country: { type: string } }, type: object } type: object '403': description: 'Access denied' '404': description: 'Family not found' security: - ApiKeyAuth: [] delete: tags: - Families summary: 'Delete a family record and optionally its members (DeleteRecord role required)' parameters: - name: familyId in: path required: true schema: type: integer - name: deleteMembers in: query description: 'If true, also delete all family members. If false, members are unlinked from the family.' required: false schema: type: boolean default: false responses: '200': description: 'Family deleted' '403': description: 'DeleteRecord role required, family has donations, or a member has a login the current user may not delete' '404': description: 'Family not found' security: - ApiKeyAuth: [] '/family/{familyId}/geolocation': get: tags: - Families summary: 'Get geolocation and driving distance from church for a family' parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Latitude, longitude, and driving distance info from church address' security: - ApiKeyAuth: [] '/family/{familyId}/verify': post: tags: - Families summary: 'Send a verification email to the family' parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Verification email sent successfully' '500': description: 'Error sending email' security: - ApiKeyAuth: [] '/family/{familyId}/verify/url': get: tags: - Families summary: 'Generate a new family self-verify URL token' parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Verification URL' content: application/json: schema: properties: url: { type: string, format: uri } type: object security: - ApiKeyAuth: [] '/family/{familyId}/verify/now': post: tags: - Families summary: 'Mark a family as verified immediately' parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Family marked as verified' security: - ApiKeyAuth: [] '/family/{familyId}/activate/{status}': post: tags: - Families summary: 'Activate or deactivate a family' description: 'Pass status=true to activate or status=false to deactivate the family.' parameters: - name: familyId in: path required: true schema: type: integer - name: status in: path required: true schema: type: string enum: - 'true' - 'false' responses: '200': description: 'Family activation status updated' content: application/json: schema: properties: success: { type: boolean } type: object '400': description: 'Invalid status value' security: - ApiKeyAuth: [] '/family/{familyId}/approve-review': post: tags: - Families summary: "Approve a self-registered family, clearing its needs-review flag and its members' flags" parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Family and its members approved' content: application/json: schema: properties: success: { type: boolean } type: object security: - ApiKeyAuth: [] '/family/{familyId}/geocode': post: tags: - Families summary: "Refresh geocoding (latitude/longitude) for a family's address" parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Geocoding result with lat/lng' content: application/json: schema: properties: success: { type: boolean } message: { type: string } latitude: { type: number, format: float } longitude: { type: number, format: float } type: object '400': description: 'Family has no street address to geocode' security: - ApiKeyAuth: [] '/family/{familyId}/donations/move': post: tags: - Families summary: 'Move all donations from this family to another (Finance role required)' parameters: - name: familyId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - targetFamilyId properties: targetFamilyId: description: 'Family ID to move donations to' type: integer type: object responses: '200': description: 'Donations moved' '400': description: 'Invalid target family' '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/attendance/person/{personId}': get: tags: - People summary: 'Get attendance history for a person' description: 'Returns all events a person has been checked in to (checkin_date IS NOT NULL), sorted by event start date descending, along with summary statistics including total events attended, last attendance date, and per-type attendance streaks.' operationId: getPersonAttendanceHistory parameters: - name: personId in: path description: 'Person ID' required: true schema: type: integer example: 104 responses: '200': description: 'Attendance history and summary stats' content: application/json: schema: properties: records: { type: array, items: { properties: { attendId: { type: integer, example: 1 }, eventId: { type: integer, example: 3 }, eventUrl: { type: string, example: /event/view/3 }, eventTitle: { type: string, example: 'Summer Camp' }, eventTypeId: { type: integer, example: 2, nullable: true }, eventTypeName: { type: string, example: 'Sunday School' }, eventStart: { type: string, format: date-time, example: '2017-06-06 09:30:00' }, eventEnd: { type: string, format: date-time, example: '2017-06-11 09:30:00' }, checkinDate: { type: string, format: date-time, example: '2017-04-15 17:23:46', nullable: true }, checkoutDate: { type: string, format: date-time, example: null, nullable: true }, eventInactive: { type: boolean, example: false } }, type: object } } summary: { properties: { totalEvents: { type: integer, example: 5 }, lastAttendanceDate: { type: string, format: date-time, example: '2017-06-06 09:30:00', nullable: true }, streaks: { type: array, items: { properties: { typeId: { type: integer, example: 2, nullable: true }, typeName: { type: string, example: 'Sunday School' }, length: { type: integer, example: 3 } }, type: object } } }, type: object } type: object '401': description: Unauthorized '403': description: 'Forbidden — caller does not have permission to view this person' '404': description: 'Person not found' security: - ApiKeyAuth: [] '/timeline/person/{personId}': get: tags: - People summary: 'Get full timeline for a person' description: "Returns all timeline items (notes and events) for a person, filtered by the current user's permissions. Requires authentication. Notes are only included for users with Notes=1 or Admin. Plain-auth users see only non-note items (calendar events, system events). Private notes are visible only to the author and admins; Notes=1 non-admin users see their own private notes only." operationId: getPersonTimeline parameters: - name: personId in: path required: true schema: type: integer example: 1 responses: '200': description: 'Timeline items' content: application/json: schema: properties: timeline: { type: array, items: { type: object } } type: object '401': description: Unauthorized '404': description: 'Person not found' security: - ApiKeyAuth: [] '/timeline/family/{familyId}': get: tags: - People summary: 'Get full timeline for a family' description: "Returns all timeline items (notes) for a family, filtered by the current user's permissions. Requires authentication. Notes are only included for users with Notes=1 or Admin. Plain-auth users see only non-note items. Private notes are visible only to the author and admins." operationId: getFamilyTimeline parameters: - name: familyId in: path required: true schema: type: integer example: 1 responses: '200': description: 'Timeline items' content: application/json: schema: properties: timeline: { type: array, items: { type: object } } type: object '401': description: Unauthorized '403': description: 'Access denied' '404': description: 'Family not found' security: - ApiKeyAuth: [] /families/familiesInCart: get: tags: - Families summary: 'Get families whose all members are in the session cart' responses: '200': description: 'List of family IDs where every member is in the cart' content: application/json: schema: properties: familiesInCart: { type: array, items: { type: integer } } type: object security: - ApiKeyAuth: [] /families/email/without: get: tags: - Families summary: 'Get families with no email address on record' responses: '200': description: 'Families without any email address' content: application/json: schema: properties: count: { type: integer } families: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/families/search/{query}': get: tags: - Families summary: 'Search families by name, or by envelope number for finance users (max 15 results)' parameters: - name: query in: path required: true schema: type: string responses: '200': description: 'Matching families' content: application/json: schema: properties: Families: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] /families/self-register: get: tags: - Families summary: 'Get self-registered families still awaiting review' responses: '200': description: 'Self-registered families awaiting review, ordered by date entered descending' content: application/json: schema: properties: families: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] /families/self-verify: get: tags: - Families summary: 'Get the last 100 families with self-verification notes' responses: '200': description: 'Families that submitted self-verification notes' content: application/json: schema: properties: families: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] /families/pending-self-verify: get: tags: - Families summary: 'Get families with pending (unused, non-expired) self-verify tokens' responses: '200': description: 'Pending verification tokens with family names' content: application/json: schema: properties: families: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/families/byCheckNumber/{scanString}': get: tags: - Families summary: 'Find a family by check scan string' parameters: - name: scanString in: path required: true schema: type: string responses: '200': description: 'Family member matched by check scan string' security: - ApiKeyAuth: [] /families/verify-email-preview: get: tags: - Families summary: 'Preview which families would receive a verification email' description: 'Returns recipient count, recipient list, families without email, and a template preview. Used by the send-confirmation modal on the People Verify dashboard.' operationId: getVerifyEmailPreview parameters: - name: familyId in: query description: 'Limit preview to a single family' required: false schema: type: integer responses: '200': description: 'Preview data for the send-confirmation modal' content: application/json: schema: properties: recipientCount: { type: integer } recipients: { type: array, items: { type: object } } familiesWithoutEmail: { type: array, items: { type: object } } templatePreview: { type: object } type: object '401': description: Unauthorized '403': description: 'Forbidden - insufficient role' '500': description: 'Server error' security: - ApiKeyAuth: [] /families/anniversaries: get: tags: - Families summary: 'Get families with wedding anniversaries within 7 days of today' responses: '200': description: 'Families with upcoming/recent anniversaries (±7 days)' content: application/json: schema: properties: families: { type: array, items: { properties: { FamilyId: { type: integer }, Name: { type: string }, WeddingDate: { type: string } }, type: object } } type: object security: - ApiKeyAuth: [] /families/latest: get: tags: - Families summary: 'Get the 10 most recently added families' responses: '200': description: '10 latest families by date entered' content: application/json: schema: properties: families: { type: array, items: { properties: { FamilyId: { type: integer }, Name: { type: string }, Created: { type: string, format: date-time } }, type: object } } type: object security: - ApiKeyAuth: [] /families/updated: get: tags: - Families summary: 'Get the 10 most recently updated families' responses: '200': description: '10 families ordered by last edit date descending' content: application/json: schema: properties: families: { type: array, items: { properties: { FamilyId: { type: integer }, Name: { type: string }, LastEdited: { type: string, format: date-time } }, type: object } } type: object security: - ApiKeyAuth: [] '/person/{personId}/photo': get: tags: - People summary: "Get a person's uploaded photo" description: 'Returns the binary photo image. Returns 404 if no photo has been uploaded (use avatar endpoint for fallback).' operationId: getPersonPhoto parameters: - name: personId in: path required: true schema: type: integer example: 42 responses: '200': description: 'Photo image' content: image/jpeg: schema: type: string format: binary '401': description: Unauthorized '404': description: 'No uploaded photo for this person' security: - ApiKeyAuth: [] post: tags: - People summary: "Upload a person's photo (base64 encoded)" description: 'Upload a base64-encoded image file for a person. Supported formats: PNG, JPEG, JPG, GIF, WEBP. Maximum size: 10MB.' operationId: uploadPersonPhoto parameters: - name: personId in: path required: true schema: type: integer example: 42 requestBody: description: 'Base64 encoded image data' required: true content: application/json: schema: required: - imgBase64 properties: imgBase64: description: 'Base64-encoded image data with data URI prefix' type: string example: 'data:image/jpeg;base64,/9j/4AAQSkZJRg...' type: object responses: '200': description: 'Photo uploaded successfully' content: application/json: schema: properties: success: { type: boolean, example: true } hasPhoto: { type: boolean, example: true } type: object '400': description: 'Invalid image data or upload failed' '401': description: Unauthorized '403': description: 'EditRecords role required' '404': description: 'Person not found' '413': description: 'PHP discarded the request body because it exceeded the server upload limit' security: - ApiKeyAuth: [] delete: tags: - People summary: "Delete a person's uploaded photo" description: 'Remove the uploaded photo for a person. This operation is idempotent and will return success even if no photo exists.' operationId: deletePersonPhoto parameters: - name: personId in: path required: true schema: type: integer example: 42 responses: '200': description: 'Photo deleted successfully' content: application/json: schema: properties: success: { type: boolean, example: true } type: object '401': description: Unauthorized '403': description: 'DeleteRecords role required' security: - ApiKeyAuth: [] '/person/{personId}/avatar': get: tags: - People summary: "Get a person's avatar info (initials, gravatar)" description: 'Returns JSON with avatar metadata for client-side rendering. Always returns a result even if no photo is uploaded.' operationId: getPersonAvatar parameters: - name: personId in: path required: true schema: type: integer example: 42 responses: '200': description: 'Avatar info' content: application/json: schema: properties: hasPhoto: { type: boolean } initials: { type: string, example: JS } gravatarUrl: { type: string, nullable: true } photoUrl: { type: string, nullable: true } email: { type: string, nullable: true } photoVersion: { description: 'Unix mtime of the uploaded photo file; 0 when hasPhoto is false. Append as ?v= to the /photo URL to bust the 2-hour public Cache-Control header after an upload.', type: integer, example: 1718000000 } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] '/person/{personId}': get: tags: - People summary: "Get a person's full record by ID" description: 'Returns the complete person object including family info, addresses, and other related details.' operationId: getPerson parameters: - name: personId in: path required: true schema: type: integer example: 42 responses: '200': description: 'Person object' content: application/json: schema: type: object example: id: 42 firstName: John lastName: Doe '401': description: Unauthorized '404': description: 'Person not found' security: - ApiKeyAuth: [] delete: tags: - People summary: 'Delete a person record' description: 'Permanently delete a person and all their associated records. A person who has a login can only be deleted by an administrator, never by themselves, and the last administrator cannot be deleted.' operationId: deletePerson parameters: - name: personId in: path required: true schema: type: integer example: 42 responses: '200': description: 'Person deleted successfully' content: application/json: schema: properties: success: { type: boolean, example: true } type: object '401': description: Unauthorized '403': description: 'DeleteRecords role required, or this person has a login that the current user may not delete' '404': description: 'Person not found' security: - ApiKeyAuth: [] '/person/{personId}/addToCart': post: tags: - People summary: 'Add a person to the cart' description: "Add a person to the current user's cart for batch operations." operationId: addPersonToCart parameters: - name: personId in: path required: true schema: type: integer example: 42 responses: '200': description: 'Person added to cart' content: application/json: schema: properties: success: { type: boolean, example: true } type: object '401': description: Unauthorized '404': description: 'Person not found' security: - ApiKeyAuth: [] '/person/{personId}/activate/{status}': post: tags: - People summary: 'Activate or deactivate a person (EditRecords role required)' description: 'Pass status=true to activate or status=false to deactivate the person. Cannot deactivate yourself.' operationId: activatePerson parameters: - name: personId in: path required: true schema: type: integer example: 42 - name: status in: path description: 'true to activate, false to deactivate' required: true schema: type: string enum: - 'true' - 'false' responses: '200': description: 'Person activation status updated' content: application/json: schema: properties: success: { type: boolean, example: true } type: object '400': description: 'Invalid status value' '401': description: Unauthorized '403': description: 'Cannot deactivate yourself, the only administrator who can sign in, or EditRecords role required' '404': description: 'Person not found' security: - ApiKeyAuth: [] '/person/{personId}/approve-review': post: tags: - People summary: 'Approve a self-registered person who is not part of a self-registered family, clearing their needs-review flag' parameters: - name: personId in: path required: true schema: type: integer responses: '200': description: 'Person approved' content: application/json: schema: properties: success: { type: boolean } type: object '400': description: 'Person belongs to a self-registered family (approve the family instead), or is not a pending self-registration' security: - ApiKeyAuth: [] '/person/{personId}/role/{roleId}': post: tags: - People summary: "Set a person's family role" operationId: setPersonRole parameters: - name: personId in: path required: true schema: type: integer example: 42 - name: roleId in: path description: 'Role ID from GET /persons/roles' required: true schema: type: integer example: 1 responses: '200': description: 'Role updated' content: application/json: schema: properties: success: { type: boolean, example: true } msg: { type: string, example: 'The role is successfully assigned.' } type: object '401': description: Unauthorized '403': description: 'EditRecords role required' '404': description: 'Person or role not found' '500': description: 'Failed to save role' security: - ApiKeyAuth: [] '/groups/{groupId}/formprops/{propId}/order': put: tags: - Groups summary: 'Move a group form property up or down (ManageGroup role required)' parameters: - name: groupId in: path required: true schema: type: integer - name: propId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - direction properties: direction: type: string enum: [up, down] type: object responses: '200': description: 'Property reordered' '400': description: 'Invalid direction or field' '403': description: 'ManageGroup role required' '404': description: 'Group not found' security: - ApiKeyAuth: [] '/groups/{groupId}/formprops/{propId}': delete: tags: - Groups summary: 'Delete a group form property and its column (ManageGroup role required)' parameters: - name: groupId in: path required: true schema: type: integer - name: propId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - field properties: field: description: 'Column field name (e.g. c1, c2)' type: string example: c1 type: object responses: '200': description: 'Property deleted' '400': description: 'Invalid field name' '403': description: 'ManageGroup role required' '404': description: 'Group not found' security: - ApiKeyAuth: [] /people/properties/person: get: tags: - Properties summary: 'Get all available person property definitions' responses: '200': description: 'Array of person property definitions' '403': description: 'MenuOptions role required' security: - ApiKeyAuth: [] '/people/properties/person/{personId}/{propertyId}': post: tags: - Properties summary: 'Add or update a property on a person (EditRecords role required)' parameters: - name: personId in: path required: true schema: type: integer - name: propertyId in: path required: true schema: type: integer requestBody: content: application/json: schema: properties: value: description: 'Property value (required only when property has a prompt)' type: string type: object responses: '200': description: 'Property assigned successfully' '403': description: 'EditRecords role required' security: - ApiKeyAuth: [] delete: tags: - Properties summary: 'Remove a property from a person (EditRecords role required)' parameters: - name: personId in: path required: true schema: type: integer - name: propertyId in: path required: true schema: type: integer responses: '200': description: 'Property removed successfully' '404': description: 'Record not found' '403': description: 'EditRecords role required' security: - ApiKeyAuth: [] /people/properties/family: get: tags: - Properties summary: 'Get all available family property definitions' responses: '200': description: 'Array of family property definitions' '403': description: 'MenuOptions role required' security: - ApiKeyAuth: [] '/people/properties/person/{personId}': get: tags: - Properties summary: 'Get properties assigned to a specific person' parameters: - name: personId in: path required: true schema: type: integer responses: '200': description: 'Array of assigned property records with edit/delete permissions' content: application/json: schema: type: array items: properties: { id: { type: integer }, name: { type: string }, value: { type: string }, allowEdit: { type: boolean }, allowDelete: { type: boolean } } type: object '403': description: 'EditRecords role required' security: - ApiKeyAuth: [] '/people/properties/family/{familyId}': get: tags: - Properties summary: 'Get properties assigned to a specific family' parameters: - name: familyId in: path required: true schema: type: integer responses: '200': description: 'Array of assigned property records with edit/delete permissions' content: application/json: schema: type: array items: properties: { id: { type: integer }, name: { type: string }, value: { type: string }, allowEdit: { type: boolean }, allowDelete: { type: boolean } } type: object '403': description: 'EditRecords role required' security: - ApiKeyAuth: [] '/people/properties/family/{familyId}/{propertyId}': post: tags: - Properties summary: 'Add or update a property on a family (EditRecords role required)' parameters: - name: familyId in: path required: true schema: type: integer - name: propertyId in: path required: true schema: type: integer requestBody: content: application/json: schema: properties: value: description: 'Property value (required only when property has a prompt)' type: string type: object responses: '200': description: 'Property assigned successfully' '403': description: 'EditRecords role required' security: - ApiKeyAuth: [] delete: tags: - Properties summary: 'Remove a property from a family (EditRecords role required)' parameters: - name: familyId in: path required: true schema: type: integer - name: propertyId in: path required: true schema: type: integer responses: '200': description: 'Property removed successfully' '404': description: 'Record not found' '403': description: 'EditRecords role required' security: - ApiKeyAuth: [] '/people/properties/definition/{propertyId}': delete: tags: - Properties summary: 'Delete a property definition and all its assignments (MenuOptions role required)' parameters: - name: propertyId in: path required: true schema: type: integer responses: '200': description: 'Property definition deleted' '404': description: 'Property not found' '403': description: 'MenuOptions role required' security: - ApiKeyAuth: [] '/person/{personId}/notes': get: tags: - People summary: 'List user-type notes for a person' description: "Returns all notes of type 'note' for the person, filtered by visibility for the current user. Requires Notes=1 or Admin. Private notes authored by others are excluded for Notes=1 users; admins see all including private." operationId: getPersonNotes parameters: - name: personId in: path required: true schema: type: integer example: 1 responses: '200': description: 'Note list' content: application/json: schema: properties: notes: { type: array, items: { properties: { id: { type: integer }, perId: { type: integer }, famId: { type: integer }, text: { type: string }, private: { type: boolean }, type: { type: string }, dateEntered: { type: string, format: date-time }, dateLastEdited: { type: string, format: date-time, nullable: true }, enteredBy: { type: integer }, editedBy: { type: integer } }, type: object } } type: object '401': description: Unauthorized '403': description: 'Notes read permission required' '404': description: 'Person not found' security: - ApiKeyAuth: [] '/person/{personId}/note': post: tags: - People summary: 'Create a note for a person' description: "Creates a note on a person's record. Requires Notes=1 or Admin (via NotesRoleAuthMiddleware) plus canEditPerson() to scope writes: EditRecords/Admin may write on any person; EditSelf users only on their own family members; Notes=1-only users (no EditRecords/EditSelf) are blocked." operationId: createPersonNote parameters: - name: personId in: path required: true schema: type: integer example: 1 requestBody: required: true content: application/json: schema: required: - text properties: text: description: 'HTML note content (Quill output)' type: string private: description: 'Mark note as private (visible to admins and author only)' type: boolean example: false type: object responses: '201': description: 'Note created' content: application/json: schema: properties: note: { type: object } type: object '400': description: 'Note text is required' '401': description: Unauthorized '403': description: 'Notes permission required or canEditPerson() denied' '404': description: 'Person not found' security: - ApiKeyAuth: [] '/family/{familyId}/notes': get: tags: - Families summary: 'List user-type notes for a family' description: "Returns all notes of type 'note' for the family, filtered by visibility for the current user. Requires Notes=1 or Admin. Private notes authored by others are excluded for Notes=1 users; admins see all including private." operationId: getFamilyNotes parameters: - name: familyId in: path required: true schema: type: integer example: 1 responses: '200': description: 'Note list' content: application/json: schema: properties: notes: { type: array, items: { properties: { id: { type: integer }, famId: { type: integer }, text: { type: string }, private: { type: boolean }, dateEntered: { type: string, format: date-time }, dateLastEdited: { type: string, format: date-time, nullable: true }, enteredBy: { type: integer }, editedBy: { type: integer } }, type: object } } type: object '401': description: Unauthorized '403': description: 'Notes read permission required' '404': description: 'Family not found' security: - ApiKeyAuth: [] '/family/{familyId}/note': post: tags: - Families summary: 'Create a note for a family' description: 'Creates a note on a family record. Requires Notes=1 or Admin. Cross-family writes are intentional (e.g. visitation teams adding notes on families they visit). See canWriteNoteOnFamily().' operationId: createFamilyNote parameters: - name: familyId in: path required: true schema: type: integer example: 1 requestBody: required: true content: application/json: schema: required: - text properties: text: description: 'HTML note content (Quill output)' type: string private: description: 'Mark note as private (visible to admins and author only)' type: boolean example: false type: object responses: '201': description: 'Note created' content: application/json: schema: properties: note: { type: object } type: object '400': description: 'Note text is required' '401': description: Unauthorized '403': description: 'Notes permission required' '404': description: 'Family not found' security: - ApiKeyAuth: [] '/note/{noteId}': get: tags: - People summary: 'Get a single note by ID' description: 'Returns the note if it is visible to the current user. Admins see all notes including private. Notes=1 non-admin users see only public notes and their own private notes. Returns 404 for notes not visible to the current user (including private notes the caller cannot read — to avoid leaking note existence).' operationId: getNote parameters: - name: noteId in: path required: true schema: type: integer example: 1 responses: '200': description: 'Note object' content: application/json: schema: properties: note: { type: object } type: object '401': description: Unauthorized '403': description: 'Notes permission required' '404': description: 'Note not found or not visible to current user' security: - ApiKeyAuth: [] put: tags: - People summary: "Update a note's text and/or privacy" description: "Only the note's author or an admin may update a note." operationId: updateNote parameters: - name: noteId in: path required: true schema: type: integer example: 1 requestBody: required: true content: application/json: schema: required: - text properties: text: description: 'Updated HTML note content' type: string private: description: 'Update privacy flag' type: boolean example: false type: object responses: '200': description: 'Updated note' content: application/json: schema: properties: note: { type: object } type: object '400': description: 'Note text is required' '401': description: Unauthorized '403': description: 'Only the note author or an admin may edit this note' '404': description: 'Note not found' security: - ApiKeyAuth: [] delete: tags: - People summary: 'Delete a note' description: "Deletes the note. Requires Notes permission. Only the note's author or an admin may delete it." operationId: deleteNote parameters: - name: noteId in: path required: true schema: type: integer example: 1 responses: '200': description: 'Note deleted' content: application/json: schema: properties: success: { type: boolean } type: object '401': description: Unauthorized '403': description: 'Notes permission required or not note author/admin' '404': description: 'Note not found' security: - ApiKeyAuth: [] /groups/: get: tags: - Groups summary: 'List all groups' responses: '200': description: 'Array of all groups' security: - ApiKeyAuth: [] post: tags: - Groups summary: 'Create a new group (ManageGroupRole role required)' requestBody: required: true content: application/json: schema: required: - groupName properties: groupName: type: string description: type: string isSundaySchool: type: boolean groupType: type: integer type: object responses: '200': description: 'Newly created group object' '403': description: 'ManageGroupRole role required' security: - ApiKeyAuth: [] /groups/calendars: get: tags: - Groups summary: 'Get groups formatted for calendar display' responses: '200': description: 'Groups with type, groupID, and name fields' content: application/json: schema: type: array items: properties: { type: { type: string, example: group }, groupID: { type: integer }, name: { type: string } } type: object security: - ApiKeyAuth: [] /groups/groupsInCart: get: tags: - Groups summary: 'Get IDs of groups whose all members are in the session cart' responses: '200': description: 'Group IDs where every member is in the cart' content: application/json: schema: properties: groupsInCart: { type: array, items: { type: integer } } type: object security: - ApiKeyAuth: [] '/groups/{groupID}': get: tags: - Groups summary: 'Get a single group by ID' parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Group object' '404': description: 'Group not found' security: - ApiKeyAuth: [] post: tags: - Groups summary: 'Update group name, type, and description (ManageGroupRole role required)' parameters: - name: groupID in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: groupName: type: string groupType: type: integer description: type: string type: object responses: '200': description: 'Updated group object' '403': description: 'ManageGroupRole role required' '409': description: "The group is a volunteer ministry's pool and is managed from that ministry (Volunteer v2, D19), or is a Sunday School class linked to a volunteer team and would stop being one (D23)" security: - ApiKeyAuth: [] delete: tags: - Groups summary: 'Delete a group (ManageGroupRole role required)' description: "Deletes a group. Members and custom properties are cascade-deleted. Returns 409 Conflict if the group is the audience for one or more events, is referenced by an event type — reassign those first — or is a volunteer ministry's pool, which is deleted with the ministry." parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Group deleted successfully' '403': description: 'ManageGroupRole role required' '409': description: "Group is the audience for active events, referenced by event types, or is a volunteer ministry's pool (Volunteer v2, D19)" security: - ApiKeyAuth: [] '/groups/{groupID}/cartStatus': get: tags: - Groups summary: 'Check whether all members of a group are in the session cart' parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Cart status for the group' content: application/json: schema: properties: isInCart: { type: boolean } type: object security: - ApiKeyAuth: [] '/groups/{groupID}/members': get: tags: - Groups summary: 'Get members of a group with family address info' parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Group members enriched with family address fields' content: application/json: schema: properties: Person2group2roleP2g2rs: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/groups/{groupID}/events': get: tags: - Groups summary: 'Get group member-role memberships (events/roles per member)' parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Person-to-group-to-role membership records' security: - ApiKeyAuth: [] '/groups/{groupID}/roles': get: tags: - Groups summary: 'Get the role options for a group' parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Array of role list options for the group' security: - ApiKeyAuth: [] post: tags: - Groups summary: 'Add a new role to a group (ManageGroupRole role required)' parameters: - name: groupID in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: roleName: type: string type: object responses: '200': description: 'New role created' '403': description: 'ManageGroupRole role required' '500': description: 'Failed to add role' security: - ApiKeyAuth: [] '/groups/{groupID}/phones': get: tags: - Groups summary: 'Get cell phone numbers for group members (respects Do Not SMS)' parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Phone contact info for the group' content: application/json: schema: properties: phones: { description: 'Raw phone numbers', type: array, items: { type: string } } displayList: { description: 'Comma-separated display string', type: string } type: object security: - ApiKeyAuth: [] '/groups/{groupID}/sundayschool/phones': get: tags: - Groups summary: 'Get cell phone numbers for a Sunday School class, segmented by role' parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Phone contact info segmented by role (all/teachers/parents)' content: application/json: schema: properties: all: { type: object } teachers: { type: object } students: { type: object } parents: { type: object } type: object security: - ApiKeyAuth: [] '/groups/{groupID}/emails': get: tags: - Groups summary: 'Get mailing email addresses for group members grouped by role' description: 'Returns member email addresses for all group members, excluding those with the DoNotEmail property. sToEmailAddress is a system setting added by the composer, not returned here.' parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Email addresses for the group' content: application/json: schema: properties: emails: { description: 'All unique member email addresses (does not include sToEmailAddress)', type: array, items: { type: string } } byRole: { description: 'Emails grouped by group role name', type: object, additionalProperties: { type: array, items: { type: string } } } recipients: { description: 'The same people with ids, for POST /api/email/send', type: array, items: { properties: { personId: { type: integer }, familyId: { type: integer, nullable: true }, name: { type: string }, email: { type: string } }, type: object } } type: object '401': description: Unauthorized '403': description: 'Email permission required' '404': description: 'Group not found' security: - ApiKeyAuth: [] '/groups/{groupID}/sundayschool/emails': get: tags: - Groups summary: 'Get email addresses for a Sunday School class, segmented by role' parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Email contact info segmented by role (teachers/parents/kids)' content: application/json: schema: properties: emails: { description: 'All unique addresses', type: array, items: { type: string } } byRole: { description: 'Emails grouped by Teachers / Parents / Kids', type: object, additionalProperties: { type: array, items: { type: string } } } recipients: { description: 'The same people with ids, for POST /api/email/send', type: array, items: { properties: { personId: { type: integer }, familyId: { type: integer, nullable: true }, name: { type: string }, email: { type: string } }, type: object } } all: { description: 'Legacy CSV of all addresses', type: string } teachers: { description: 'Legacy CSV', type: string } parents: { description: 'Legacy CSV', type: string } kids: { description: 'Legacy CSV', type: string } type: object '401': description: Unauthorized '403': description: 'Email permission required' '404': description: 'Group not found' security: - ApiKeyAuth: [] /groups/sundayschool/export/email: get: tags: - Groups summary: 'Export people emails with group memberships as CSV' responses: '200': description: 'CSV file with people emails and group role memberships' '403': description: 'ManageGroups role required' security: - ApiKeyAuth: [] /groups/sundayschool/export/classlist: get: tags: - Groups summary: 'Export Sunday School class roster as CSV' responses: '200': description: 'CSV file with class members, parents, and contact info' '403': description: 'ManageGroups role required or Sunday School disabled' security: - ApiKeyAuth: [] '/groups/{groupID}/removeperson/{userID}': delete: tags: - Groups summary: 'Remove a person from a group (ManageGroupRole role required)' parameters: - name: groupID in: path required: true schema: type: integer - name: userID in: path required: true schema: type: integer responses: '200': description: 'Person removed from group' '403': description: 'ManageGroupRole role required' '409': description: 'The person is a teacher of a Sunday School class whose teachers are managed by a volunteer team (Volunteer v2, D23)' security: - ApiKeyAuth: [] '/groups/{groupID}/addperson/{userID}': post: tags: - Groups summary: 'Add a person to a group (ManageGroupRole role required)' parameters: - name: groupID in: path required: true schema: type: integer - name: userID in: path required: true schema: type: integer requestBody: required: false content: application/json: schema: properties: RoleID: description: 'Defaults to group default role if omitted' type: integer type: object responses: '200': description: 'Updated membership records for the person in the group' '403': description: 'ManageGroupRole role required' '409': description: 'The role is Teacher, or the person is a teacher, of a Sunday School class whose teachers are managed by a volunteer team (Volunteer v2, D23)' security: - ApiKeyAuth: [] '/groups/{groupID}/userRole/{userID}': post: tags: - Groups summary: "Update a group member's role (ManageGroupRole role required)" parameters: - name: groupID in: path required: true schema: type: integer - name: userID in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: roleID: type: integer type: object responses: '200': description: 'Updated membership object' '400': description: "roleID is not one of the group's roles" '403': description: 'ManageGroupRole role required' '404': description: 'Group or membership not found' '409': description: 'The change is to or from Teacher in a Sunday School class whose teachers are managed by a volunteer team (Volunteer v2, D23)' security: - ApiKeyAuth: [] '/groups/{groupID}/roles/{roleID}': post: tags: - Groups summary: 'Update a group role name or sort order (ManageGroupRole role required)' parameters: - name: groupID in: path required: true schema: type: integer - name: roleID in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: groupRoleName: description: 'New role name' type: string groupRoleOrder: description: 'New sort order' type: integer type: object responses: '200': description: 'Role updated successfully' '403': description: 'ManageGroupRole role required' '404': description: 'Group role not found' '409': description: 'Renaming the Teacher role of a Sunday School class whose teachers are managed by a volunteer team (Volunteer v2, D23)' '500': description: 'Failed to update role' security: - ApiKeyAuth: [] delete: tags: - Groups summary: 'Delete a group role (ManageGroupRole role required)' parameters: - name: groupID in: path required: true schema: type: integer - name: roleID in: path required: true schema: type: integer responses: '200': description: 'Role deleted; returns the remaining roles. Members who had it move to the default role (the first remaining role if it was the default)' '400': description: "The group's only role cannot be deleted" '403': description: 'ManageGroupRole role required' '404': description: 'Group or role not found' '409': description: 'Deleting the Teacher role of a Sunday School class whose teachers are managed by a volunteer team (Volunteer v2, D23)' '500': description: 'Failed to delete role' security: - ApiKeyAuth: [] '/groups/{groupID}/defaultRole': post: tags: - Groups summary: 'Set the default role for a group (ManageGroupRole role required)' parameters: - name: groupID in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: roleID: type: integer type: object responses: '200': description: 'Default role updated' '403': description: 'ManageGroupRole role required' security: - ApiKeyAuth: [] '/groups/{groupID}/setGroupSpecificPropertyStatus': post: tags: - Groups summary: 'Enable or disable group-specific properties (ManageGroupRole role required)' parameters: - name: groupID in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: GroupSpecificPropertyStatus: type: boolean type: object responses: '200': description: 'Property status updated' content: application/json: schema: properties: status: { type: string } type: object '403': description: 'ManageGroupRole role required' '500': description: 'Failed to update properties' security: - ApiKeyAuth: [] '/groups/{groupID}/settings/active/{value}': post: tags: - Groups summary: "Set a group's active status (ManageGroupRole role required)" parameters: - name: groupID in: path required: true schema: type: integer - name: value in: path required: true schema: type: string enum: - 'true' - 'false' responses: '200': description: 'Active status updated' '403': description: 'ManageGroupRole role required' security: - ApiKeyAuth: [] '/groups/{groupID}/settings/email/export/{value}': post: tags: - Groups summary: 'Set the email export flag for a group (ManageGroupRole role required)' parameters: - name: groupID in: path required: true schema: type: integer - name: value in: path required: true schema: type: string enum: - 'true' - 'false' responses: '200': description: 'Email export flag updated' '403': description: 'ManageGroupRole role required' security: - ApiKeyAuth: [] /persons/email/without: get: tags: - People summary: 'Get people with no personal or work email on record' responses: '200': description: 'People without any email address' content: application/json: schema: properties: count: { type: integer } persons: { type: array, items: { type: object } } type: object security: - ApiKeyAuth: [] '/persons/search/{query}': get: tags: - People summary: 'Search persons by name or email' operationId: searchPersons parameters: - name: query in: path description: 'Search term (name or email)' required: true schema: type: string example: John responses: '200': description: 'Array of matching persons (max 15)' content: application/json: schema: type: array items: properties: { id: { type: integer }, objid: { type: integer }, text: { type: string, example: 'John Smith' }, uri: { type: string, example: /people/view/42 } } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] /persons/self-register: get: tags: - People summary: 'List recently self-registered persons still awaiting review' description: 'Returns self-registered persons still awaiting review who are not part of a self-registered family, newest first: individuals from the public form, and people proposed for an existing family in the Member Portal (with FamilyName set).' operationId: getSelfRegisteredPersons responses: '200': description: 'List of self-registered persons' content: application/json: schema: properties: people: { type: array, items: { type: object } } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] /persons/self-register/count: get: tags: - People summary: 'Count self-registered families and people reviewed on their own that await review' responses: '200': description: 'Pending count' content: application/json: schema: properties: count: { type: integer } type: object security: - ApiKeyAuth: [] /persons/self-register/approve: post: tags: - People summary: 'Approve several self-registered families and people at once' description: 'Clears the needs-review flag on the listed self-registered families (and their members) and people reviewed on their own (not part of a self-registered family). Ids that are not pending self-registrations are ignored.' requestBody: required: true content: application/json: schema: properties: families: type: array items: { type: integer } persons: type: array items: { type: integer } type: object responses: '200': description: Approved content: application/json: schema: properties: success: { type: boolean } approved: { type: integer } type: object '403': description: 'EditRecords permission required' security: - ApiKeyAuth: [] /persons/roles: get: tags: - People summary: 'List all family roles' description: 'Returns all family role options (e.g. Head of Household, Spouse, Child) used when registering persons.' operationId: getAllRoles responses: '200': description: 'Array of role objects' content: application/json: schema: type: array items: properties: { OptionId: { type: integer, example: 1 }, OptionName: { type: string, example: 'Head of Household' } } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] /persons/duplicate/emails: get: tags: - People summary: 'Find duplicate email addresses across persons and families' operationId: getEmailDuplicates responses: '200': description: 'Emails used by more than one record' content: application/json: schema: properties: emails: { type: array, items: { properties: { email: { type: string, format: email }, people: { type: array, items: { properties: { id: { type: integer }, name: { type: string } }, type: object } }, families: { type: array, items: { properties: { id: { type: integer }, name: { type: string } }, type: object } } }, type: object } } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] /persons/latest: get: tags: - People summary: 'List the 10 most recently added persons' operationId: getLatestPersons responses: '200': description: 'Recent persons with family info' content: application/json: schema: properties: people: { type: array, items: { properties: { PersonId: { type: integer }, FirstName: { type: string }, LastName: { type: string }, Email: { type: string }, FamilyId: { type: integer, nullable: true }, Created: { type: string, format: date-time } }, type: object } } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] /persons/updated: get: tags: - People summary: 'List the 10 most recently edited persons' operationId: getUpdatedPersons responses: '200': description: 'Recently updated persons' content: application/json: schema: properties: people: { type: array, items: { properties: { PersonId: { type: integer }, FirstName: { type: string }, LastName: { type: string }, LastEdited: { type: string, format: date-time } }, type: object } } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] /persons/birthday: get: tags: - People summary: 'List persons with birthdays in a ±7-day window around today' operationId: getPersonsWithBirthdays responses: '200': description: 'Persons with upcoming or recent birthdays, sorted by days until birthday' content: application/json: schema: properties: people: { type: array, items: { properties: { PersonId: { type: integer }, FirstName: { type: string }, LastName: { type: string }, Age: { type: integer, nullable: true }, DaysUntil: { description: 'Negative = past, 0 = today, positive = upcoming', type: integer }, Birthday: { type: string, example: 'March 15' } }, type: object } } type: object '401': description: Unauthorized security: - ApiKeyAuth: [] /people/emails: get: tags: - People summary: 'Get mailing email addresses grouped by classification role' description: 'Returns member email addresses for all active-family people, excluding those with the DoNotEmail property. sToEmailAddress is a system setting added by the composer, not returned here.' responses: '200': description: 'Email addresses for all people and per role' content: application/json: schema: properties: emails: { description: 'All unique member email addresses (does not include sToEmailAddress)', type: array, items: { type: string } } byRole: { description: 'Emails grouped by classification role name', type: object, additionalProperties: { type: array, items: { type: string } } } recipients: { description: 'The same people with ids, for POST /api/email/send', type: array, items: { properties: { personId: { type: integer }, familyId: { type: integer, nullable: true }, name: { type: string }, email: { type: string } }, type: object } } type: object '401': description: Unauthorized '403': description: 'Email permission required' security: - ApiKeyAuth: [] /groups/properties: get: tags: - Groups - Properties summary: 'Get all available group property definitions' responses: '200': description: 'Array of group property definitions' '403': description: 'MenuOptions role required' security: - ApiKeyAuth: [] '/groups/{groupID}/properties': get: tags: - Groups - Properties summary: 'Get properties assigned to a specific group' parameters: - name: groupID in: path required: true schema: type: integer responses: '200': description: 'Array of assigned properties with edit/delete flags' content: application/json: schema: type: array items: properties: { id: { type: integer }, name: { type: string }, value: { type: string }, prompt: { type: string }, allowEdit: { type: boolean }, allowDelete: { type: boolean } } type: object '403': description: 'MenuOptions role required' '404': description: 'Group not found' security: - ApiKeyAuth: [] '/groups/{groupID}/properties/{propertyId}': post: tags: - Groups - Properties summary: 'Assign or update a property on a group (ManageGroup role required)' parameters: - name: groupID in: path required: true schema: type: integer - name: propertyId in: path required: true schema: type: integer requestBody: content: application/json: schema: properties: value: description: 'Property value (required only when the property has a prompt)' type: string type: object responses: '200': description: 'Property assigned successfully' '403': description: 'ManageGroup role required' '404': description: 'Group or property not found' security: - ApiKeyAuth: [] delete: tags: - Groups - Properties summary: 'Remove a property from a group (ManageGroup role required)' parameters: - name: groupID in: path required: true schema: type: integer - name: propertyId in: path required: true schema: type: integer responses: '200': description: 'Property removed successfully' '404': description: 'Assignment not found' '403': description: 'ManageGroup role required' security: - ApiKeyAuth: [] /cart/emails: get: tags: - Cart summary: 'Get email addresses for all people currently in the session cart' responses: '200': description: 'Email list for the current cart' content: application/json: schema: properties: emails: { description: 'Unique cart member email addresses (does not include sToEmailAddress, which the composer adds from the setting)', type: array, items: { type: string } } recipients: { description: 'The same people with ids, for POST /api/email/send', type: array, items: { properties: { personId: { type: integer }, familyId: { type: integer, nullable: true }, name: { type: string }, email: { type: string } }, type: object } } type: object '401': description: Unauthorized '403': description: 'Email permission required' security: - ApiKeyAuth: [] /cart/: get: tags: - Cart summary: 'Get the current session people cart contents' responses: '200': description: 'Array of person IDs currently in the cart' content: application/json: schema: properties: PeopleCart: { type: array, items: { type: integer } } type: object security: - ApiKeyAuth: [] post: tags: - Cart summary: 'Add persons, a family, or a group to the session cart' requestBody: required: true content: application/json: schema: properties: Persons: description: 'Array of person IDs to add' type: array items: { type: integer } Family: description: 'Family ID to add all members from' type: integer Group: description: 'Group ID to add all members from' type: integer type: object responses: '200': description: 'Persons, family, or group added to cart' '400': description: 'Invalid or missing request data' security: - ApiKeyAuth: [] delete: tags: - Cart summary: 'Remove persons or a family from the cart, or empty the entire cart' requestBody: content: application/json: schema: description: 'Omit body to empty the entire cart' properties: Persons: description: 'Person IDs to remove' type: array items: { type: integer } Family: description: 'Family ID — removes all family members from cart' type: integer type: object responses: '200': description: 'Cart updated' content: application/json: schema: properties: status: { type: string } message: { type: string } type: object '400': description: 'Invalid request data' security: - ApiKeyAuth: [] /cart/emptyToGroup: post: tags: - Cart summary: 'Move all persons in the cart into a group with a specified role' requestBody: required: true content: application/json: schema: required: - groupID - groupRoleID properties: groupID: type: integer groupRoleID: type: integer type: object responses: '200': description: 'Cart persons added to the group' '400': description: 'Invalid or missing groupID/groupRoleID' security: - ApiKeyAuth: [] /cart/removeGroup: post: tags: - Cart summary: 'Remove all members of a group from the session cart' requestBody: required: true content: application/json: schema: required: - Group properties: Group: description: 'Group ID whose members should be removed from the cart' type: integer type: object responses: '200': description: 'Group members removed from cart' '400': description: 'Invalid or missing Group ID' security: - ApiKeyAuth: [] '/api/user/{userId}/password/reset': post: tags: - Admin summary: "Reset a user's password to a random value and email it to them (Admin role required)" parameters: - name: userId in: path required: true schema: type: integer responses: '200': description: 'Password reset and email sent' '403': description: 'Admin role required' '409': description: 'Email delivery is disabled or SMTP is not configured, so the reset was refused' content: application/json: schema: properties: success: { type: boolean, example: false } error: { type: string } type: object '500': description: 'Password was rotated but the email could not be delivered; the user is effectively locked out' content: application/json: schema: properties: success: { type: boolean, example: false } error: { type: string } type: object security: - ApiKeyAuth: [] '/api/user/{userId}/disableTwoFactor': post: tags: - Admin summary: 'Disable two-factor authentication for a user (Admin role required)' parameters: - name: userId in: path required: true schema: type: integer responses: '200': description: '2FA disabled for the user' '403': description: 'Admin role required' security: - ApiKeyAuth: [] '/api/user/{userId}/login/reset': post: tags: - Admin summary: 'Reset failed login counter and send unlock email (Admin role required)' parameters: - name: userId in: path required: true schema: type: integer responses: '200': description: 'Login counter reset and unlock email sent' '403': description: 'Admin role required' security: - ApiKeyAuth: [] '/api/user/{userId}/': delete: tags: - Admin summary: 'Delete a user account (Admin role required)' parameters: - name: userId in: path required: true schema: type: integer responses: '200': description: 'User deleted' content: application/json: schema: properties: user: { description: 'Deleted username', type: string } type: object '403': description: 'Admin role required' security: - ApiKeyAuth: [] '/api/user/{userId}/permissions': get: tags: - Admin summary: 'Get permission flags for a user (Admin role required)' parameters: - name: userId in: path required: true schema: type: integer responses: '200': description: 'User permission data' content: application/json: schema: properties: user: { type: string } userId: { type: integer } addEvent: { type: boolean } type: object '403': description: 'Admin role required' security: - ApiKeyAuth: [] /api/orphaned-files: get: tags: - Admin summary: 'List orphaned files' description: 'Returns files present on disk that are not part of the official ChurchCRM release.' operationId: getOrphanedFiles responses: '200': description: 'Orphaned file list' content: application/json: schema: properties: count: { type: integer } files: { type: array, items: { type: string } } type: object '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' security: - ApiKeyAuth: [] /api/orphaned-files/delete-all: post: tags: - Admin summary: 'Delete all orphaned files' operationId: deleteAllOrphanedFiles responses: '200': description: 'Deletion results' content: application/json: schema: properties: success: { type: boolean } deleted: { type: array, items: { type: string } } failed: { type: array, items: { type: string } } errors: { type: array, items: { type: string } } message: { type: string } type: object '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '500': description: 'Error deleting files' security: - ApiKeyAuth: [] /api/upgrade/preview: get: tags: - Admin summary: 'Get upgrade preview: release notes and path without downloading' operationId: upgradePreview responses: '200': description: 'Upgrade preview data' content: application/json: schema: properties: installedVersion: { type: string } nextVersion: { type: string, nullable: true } latestVersion: { type: string } nextReleaseNotes: { type: string } nextChangelogUrl: { type: string, nullable: true } releasesAhead: { type: integer } upgradePath: { type: array, items: { type: object } } latestReleaseNotes: { description: 'Release notes for the latest known GitHub release; empty if release notes are unavailable or GitHub could not be reached', type: string } latestChangelogUrl: { description: 'Changelog URL for the latest known release', type: string } type: object '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '500': description: 'Error fetching preview' security: - ApiKeyAuth: [] /api/upgrade/download-latest-release: get: tags: - Admin summary: 'Download the latest release from GitHub (or a specific version)' operationId: downloadLatestRelease parameters: - name: version in: query description: 'Target version (e.g. 7.2.1). Omit for latest.' required: false schema: type: string responses: '200': description: 'Release file downloaded' content: application/json: schema: properties: fileName: { type: string } fullPath: { type: string } releaseNotes: { type: string } sha1: { type: string } type: object '400': description: 'Error downloading release' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' security: - ApiKeyAuth: [] /api/upgrade/do-upgrade: post: tags: - Admin summary: 'Apply the system upgrade' operationId: doUpgrade requestBody: required: true content: application/json: schema: properties: fullPath: description: 'Full path to the upgrade file' type: string sha1: description: 'SHA1 hash for verification' type: string type: object responses: '200': description: 'Upgrade applied successfully' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '500': description: 'Error applying upgrade' security: - ApiKeyAuth: [] /api/upgrade/refresh-upgrade-info: post: tags: - Admin summary: 'Refresh upgrade information from GitHub' description: 'Forces a fresh check of available updates from GitHub and updates session state.' operationId: refreshUpgradeInfo responses: '200': description: 'Upgrade information refreshed' content: application/json: schema: properties: data: { type: object } message: { type: string } type: object '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '500': description: 'Error refreshing upgrade information' security: - ApiKeyAuth: [] /admin/api/import/csv/families: get: tags: - Import summary: 'Download a CSV import template for families (Admin role required)' responses: '200': description: 'CSV file attachment' '401': description: Unauthorized '403': description: 'Admin role required' security: - ApiKeyAuth: [] /admin/api/import/csv/upload: post: tags: - Import summary: 'Upload a CSV file and return headers with auto-detected field mappings' requestBody: content: multipart/form-data: schema: properties: csvFile: type: string format: binary type: object responses: '200': description: 'Headers and auto-mapped suggestions returned' '400': description: 'Invalid file' '401': description: Unauthorized '403': description: 'Admin role required' security: - ApiKeyAuth: [] /admin/api/import/csv/execute: post: tags: - Import summary: 'Execute a CSV import using a previously uploaded file and column mapping' responses: '200': description: 'Import completed' '400': description: 'Invalid token or mapping' '401': description: Unauthorized '403': description: 'Admin role required' security: - ApiKeyAuth: [] /api/admin/birthday-emails/test: post: tags: - Admin summary: 'Send a test birthday email to the current admin (Admin role required)' responses: '200': description: 'Test email sent' '400': description: 'Current admin has no email on file' '500': description: 'Test email failed to send' security: - ApiKeyAuth: [] /admin/api/map/geocode-all: post: tags: - Map summary: 'Geocode all active families missing coordinates' description: "Iterates active families that have a street address but no usable coordinates, geocoding each via the configured geocoding services (Map Settings) at ~1 request/second. Families are taken in ID order; up to 50 per call. Pass 'skip' = the number of families that failed in earlier calls so those are not re-queried, and repeat while 'remaining' > 'skip'. Admin-only." requestBody: required: false content: application/json: schema: properties: skip: description: 'Families (in ID order) to skip before the batch — the running count of failures from previous calls' type: integer default: 0 minimum: 0 type: object responses: '200': description: 'Geocoding batch summary' content: application/json: schema: properties: total: { description: 'Total families missing coordinates before this run', type: integer } skip: { description: 'Offset applied to this batch (echoed back)', type: integer } processed: { description: 'Families examined in this batch (0 when skip >= total)', type: integer } geocoded: { description: 'Families successfully geocoded in this batch', type: integer } failed: { description: 'Families that could not be geocoded', type: integer } remaining: { description: 'Families still missing coordinates after this batch, including the ones that failed (run again while remaining > failures so far)', type: integer } failures: { description: 'Per-family failure details (capped at 20 entries). Empty array when all families were geocoded.', type: array, items: { properties: { id: { description: 'Family ID', type: integer }, name: { description: 'Family name', type: string }, address: { description: 'Full street address', type: string }, editUrl: { description: 'URL to the family editor page', type: string }, reason: { description: "Machine code: 'incomplete_address' | 'no_result' | 'error'", type: string } }, type: object } } failuresTruncated: { description: 'True when failed > 20 and some failures are omitted from the array', type: boolean } type: object '401': description: Unauthorized '403': description: 'Admin role required' security: - ApiKeyAuth: [] /admin/api/system/telemetry-consent: post: tags: - System summary: 'Record admin consent decision for anonymous telemetry' requestBody: required: true content: application/json: schema: properties: level: description: "Telemetry collection level. 'none' declines and suppresses prompt for this version." type: string enum: [none, errors, warnings, full] type: object responses: '403': description: 'Admin role required' '200': description: OK content: application/json: schema: properties: status: { type: string } type: object security: - ApiKeyAuth: [] /admin/api/volunteer-opportunities: get: tags: - Admin summary: 'List all volunteer opportunities (Admin role required)' parameters: - name: activeOnly in: query required: false schema: type: boolean responses: '200': description: 'Array of volunteer opportunities' '401': description: Unauthorized '403': description: 'Admin role required' security: - ApiKeyAuth: [] post: tags: - Admin summary: 'Create a new volunteer opportunity (Admin role required)' requestBody: required: true content: application/json: schema: required: - name properties: name: type: string maxLength: 30 description: type: string maxLength: 100 active: type: boolean type: object responses: '201': description: 'Newly created volunteer opportunity' '400': description: 'Validation error' '401': description: Unauthorized '403': description: 'Admin role required' security: - ApiKeyAuth: [] '/admin/api/volunteer-opportunities/{id}': get: tags: - Admin summary: 'Get a single volunteer opportunity (Admin role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: 'Volunteer opportunity object' '404': description: 'Volunteer opportunity not found' '401': description: Unauthorized '403': description: 'Admin role required' security: - ApiKeyAuth: [] put: tags: - Admin summary: 'Update a volunteer opportunity (Admin role required)' parameters: - name: id in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: name: type: string maxLength: 30 description: type: string maxLength: 100 active: type: boolean type: object responses: '200': description: 'Updated volunteer opportunity' '400': description: 'Validation error' '404': description: 'Volunteer opportunity not found' '401': description: Unauthorized '403': description: 'Admin role required' security: - ApiKeyAuth: [] delete: tags: - Admin summary: 'Delete a volunteer opportunity (Admin role required)' parameters: - name: id in: path required: true schema: type: integer responses: '200': description: Deleted '404': description: 'Volunteer opportunity not found' '409': description: 'Volunteer opportunity is still assigned to one or more people' '401': description: Unauthorized '403': description: 'Admin role required' security: - ApiKeyAuth: [] /admin/api/system/church-logo: get: tags: - Admin summary: 'Get the current church logo status' description: 'Reports whether an administrator has uploaded a church logo and the URL currently resolved for it.' operationId: getChurchLogo responses: '200': description: 'Church logo status' content: application/json: schema: properties: hasCustomLogo: { type: boolean, example: true } url: { type: string, example: '/Images/church-logo.png?v=3f2a9c1e8b7d6a54' } type: object '401': description: Unauthorized '403': description: 'Admin role required' security: - ApiKeyAuth: [] post: tags: - Admin summary: 'Upload the church logo (base64 encoded)' description: 'Accepts a base64 data URI (JPEG, PNG, GIF or WebP), downscales it to fit 1200x400 and stores it as Images/church-logo.png.' operationId: uploadChurchLogo requestBody: required: true content: application/json: schema: properties: imgBase64: type: string example: 'data:image/png;base64,iVBORw0KGgo...' type: object responses: '200': description: 'Logo stored' content: application/json: schema: properties: success: { type: boolean, example: true } hasCustomLogo: { type: boolean, example: true } url: { type: string, example: '/Images/church-logo.png?v=3f2a9c1e8b7d6a54' } type: object '400': description: 'Missing or unsupported image data' '401': description: Unauthorized '403': description: 'Admin role required' '413': description: 'Image exceeds the server upload limit or the source image size limit' security: - ApiKeyAuth: [] delete: tags: - Admin summary: 'Remove the uploaded church logo' description: 'Deletes Images/church-logo.png. Idempotent — succeeds even when no logo is stored.' operationId: deleteChurchLogo responses: '200': description: 'Logo removed' content: application/json: schema: properties: success: { type: boolean, example: true } hasCustomLogo: { type: boolean, example: false } url: { type: string, example: /Images/churchcrm-logo-ink-blue.svg } type: object '401': description: Unauthorized '403': description: 'Admin role required' '500': description: 'The stored logo could not be removed' security: - ApiKeyAuth: [] '/admin/api/system/config/{configName}': get: tags: - Admin summary: 'Get the current value of a system setting' description: 'Returns the stored value, or the shipped default when none is stored. Password settings always return an empty value.' operationId: getSystemConfigValue parameters: - name: configName in: path required: true schema: type: string example: iSMTPTimeout responses: '200': description: 'Setting value' content: application/json: schema: properties: value: { type: string, example: '10' } type: object '401': description: Unauthorized '403': description: 'Admin role required' '404': description: 'Configuration item not found' security: - ApiKeyAuth: [] post: tags: - Admin summary: 'Set the value of a system setting' description: 'An empty value for a password setting keeps the stored password. A number setting takes a number; it takes an empty value only when its shipped default is empty.' operationId: setSystemConfigValue parameters: - name: configName in: path required: true schema: type: string example: iSMTPTimeout requestBody: required: true content: application/json: schema: properties: value: type: string example: '10' type: object responses: '200': description: 'Setting saved (password settings return an empty value)' content: application/json: schema: properties: value: { type: string, example: '10' } type: object '400': description: 'A number setting was given a value that is not a number' '401': description: Unauthorized '403': description: 'Admin role required' '404': description: 'Configuration item not found' security: - ApiKeyAuth: [] /admin/api/system/custom-fields/person: get: tags: - System summary: 'Get custom person fields filtered by type ID (Admin role required)' parameters: - name: typeId in: query description: 'Person type ID to filter custom fields by' required: true schema: type: integer responses: '200': description: 'Array of id/value pairs for matching custom fields' content: application/json: schema: type: array items: properties: { id: { type: integer }, value: { type: string } } type: object '403': description: 'Admin role required' security: - ApiKeyAuth: [] /admin/api/system/properties/person: get: tags: - System summary: 'Get person property definitions as id/value pairs for settings dropdowns (Admin role required)' responses: '200': description: 'Array of id/value pairs for person properties' content: application/json: schema: type: array items: properties: { id: { type: integer }, value: { type: string } } type: object '403': description: 'Admin role required' security: - ApiKeyAuth: [] /api/demo/load: post: tags: - Admin summary: 'Import demo data into the application (Admin role required)' description: 'Only available on fresh installations with exactly 1 person, unless the force flag is set.' requestBody: required: true content: application/json: schema: properties: includeFinancial: type: boolean default: false includeEvents: type: boolean default: false includeSundaySchool: type: boolean default: false force: description: 'Skip the fresh-install guard' type: boolean default: false type: object responses: '200': description: 'Demo data imported successfully' content: application/json: schema: properties: success: { type: boolean } message: { type: string } imported: { type: object } warnings: { type: array, items: { type: string } } errors: { type: array, items: { type: string } } elapsedSeconds: { type: number } type: object '403': description: 'Admin role required — or database is not a fresh install' '500': description: 'Demo data import failed' security: - ApiKeyAuth: [] /api/database/people/export/chmeetings: get: tags: - Admin summary: 'Export all people as a ChMeetings-compatible CSV file (Admin role required)' responses: '200': description: 'CSV file download with person data in ChMeetings format' security: - ApiKeyAuth: [] /api/database/reset: delete: tags: - Admin summary: 'Drop all database tables and views, clear uploaded images, and destroy the session (Admin role required)' description: 'This operation is irreversible. After reset the session is destroyed and default credentials (admin/changeme) apply.' responses: '200': description: 'Database reset completed' content: application/json: schema: properties: success: { type: boolean } msg: { type: string } dropped: { type: integer } defaultUsername: { type: string } defaultPassword: { type: string } type: object '403': description: 'Admin role required' '500': description: 'Database reset failed' security: - ApiKeyAuth: [] /finance/api/funds/categories: get: tags: - Finance summary: 'List distinct donation fund categories' operationId: listDonationFundCategories responses: '200': description: 'Sorted category labels' content: application/json: schema: properties: categories: { type: array, items: { type: string } } type: object '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] put: tags: - Finance summary: 'Rename a category on every fund that uses it (merges if the new name exists)' operationId: renameDonationFundCategory requestBody: required: true content: application/json: schema: required: - name - newName properties: name: type: string newName: type: string maxLength: 50 type: object responses: '200': description: 'Number of funds updated' '400': description: 'Empty or too-long name' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] delete: tags: - Finance summary: 'Remove a category from every fund that uses it (funds become uncategorized)' operationId: deleteDonationFundCategory parameters: - name: name in: query required: true schema: type: string responses: '200': description: 'Number of funds updated' '400': description: 'Empty name' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] /finance/api/funds: post: tags: - Finance summary: 'Create a new donation fund (Admin only)' operationId: createDonationFundAdmin requestBody: required: true content: application/json: schema: required: - name properties: name: type: string example: 'Building Fund' maxLength: 30 description: type: string example: 'For building projects' maxLength: 100 active: type: boolean example: true category: type: string example: Missions nullable: true maxLength: 50 type: object responses: '201': description: 'Newly created fund' content: application/json: schema: properties: fund: { properties: { id: { type: integer }, name: { type: string }, description: { type: string }, active: { type: boolean }, order: { type: integer }, category: { type: string, nullable: true } }, type: object } type: object '400': description: 'Validation error (missing or duplicate name)' '401': description: Unauthorized '403': description: 'Finance role required' security: - ApiKeyAuth: [] '/finance/api/funds/{id}': put: tags: - Finance summary: 'Update a donation fund (Admin only)' operationId: updateDonationFundAdmin parameters: - name: id in: path description: 'Fund ID' required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: name: type: string maxLength: 30 description: type: string maxLength: 100 active: type: boolean category: description: 'Omit to leave unchanged; null or empty clears it' type: string nullable: true maxLength: 50 type: object responses: '200': description: 'Updated fund' '400': description: 'Validation error (blank or duplicate name)' '401': description: Unauthorized '403': description: 'Finance role required' '404': description: 'Fund not found' security: - ApiKeyAuth: [] delete: tags: - Finance summary: 'Delete a donation fund (Admin only)' operationId: deleteDonationFundAdmin parameters: - name: id in: path description: 'Fund ID' required: true schema: type: integer responses: '200': description: 'Deleted successfully' '401': description: Unauthorized '403': description: 'Finance role required' '404': description: 'Fund not found' '409': description: 'Fund is still referenced by one or more pledges' security: - ApiKeyAuth: [] '/finance/api/funds/{id}/order': patch: tags: - Finance summary: 'Move a donation fund up or down in display order (Admin only)' operationId: reorderDonationFundAdmin parameters: - name: id in: path description: 'Fund ID' required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - direction properties: direction: type: string example: up enum: [up, down] type: object responses: '200': description: 'Reordered successfully' '400': description: 'Invalid direction' '401': description: Unauthorized '403': description: 'Finance role required' '404': description: 'Fund not found' security: - ApiKeyAuth: [] /kiosk/api/devices: get: tags: - Kiosk summary: 'List all kiosk devices' operationId: getKioskDevices responses: '200': description: 'Kiosk device list' content: application/json: schema: properties: KioskDevices: { type: array, items: { properties: { Id: { type: integer }, Name: { type: string }, Accepted: { type: boolean } }, type: object } } type: object '401': description: Unauthorized '403': description: 'Forbidden — ManageGroups role required' security: - ApiKeyAuth: [] /kiosk/api/allowRegistration: post: tags: - Kiosk summary: 'Open a 2-minute kiosk registration window' description: 'Allows a new kiosk device to register itself within the next 2 minutes.' operationId: allowKioskRegistration responses: '200': description: 'Registration window opened' content: application/json: schema: properties: visibleUntil: { type: string, format: date-time } type: object '401': description: Unauthorized '403': description: 'Forbidden — ManageGroups role required' security: - ApiKeyAuth: [] '/kiosk/api/devices/{kioskId}/reload': post: tags: - Kiosk summary: 'Reload a kiosk device' operationId: reloadKiosk parameters: - name: kioskId in: path required: true schema: type: integer responses: '200': description: 'Kiosk reload triggered' '401': description: Unauthorized '403': description: 'Forbidden — ManageGroups role required' '404': description: 'Kiosk not found' security: - ApiKeyAuth: [] '/kiosk/api/devices/{kioskId}/identify': post: tags: - Kiosk summary: 'Trigger identification signal on a kiosk device' operationId: identifyKiosk parameters: - name: kioskId in: path required: true schema: type: integer responses: '200': description: 'Identification triggered' '401': description: Unauthorized '403': description: 'Forbidden — ManageGroups role required' '404': description: 'Kiosk not found' security: - ApiKeyAuth: [] '/kiosk/api/devices/{kioskId}/accept': post: tags: - Kiosk summary: 'Accept a pending kiosk device' operationId: acceptKiosk parameters: - name: kioskId in: path required: true schema: type: integer responses: '200': description: 'Kiosk accepted' '401': description: Unauthorized '403': description: 'Forbidden — ManageGroups role required' '404': description: 'Kiosk not found' security: - ApiKeyAuth: [] '/kiosk/api/devices/{kioskId}/assignment': post: tags: - Kiosk summary: 'Set kiosk event assignment' operationId: setKioskAssignment parameters: - name: kioskId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: properties: assignmentType: description: 'Assignment type ID' type: integer eventId: description: 'Event ID to assign (null to unassign)' type: integer nullable: true type: object responses: '200': description: 'Assignment updated' '400': description: 'Invalid assignment type' '401': description: Unauthorized '403': description: 'Forbidden — ManageGroups role required' '404': description: 'Kiosk not found' security: - ApiKeyAuth: [] '/kiosk/api/devices/{kioskId}': delete: tags: - Kiosk summary: 'Delete a kiosk device' operationId: deleteKiosk parameters: - name: kioskId in: path required: true schema: type: integer responses: '200': description: 'Kiosk deleted' '401': description: Unauthorized '403': description: 'Forbidden — ManageGroups role required' '404': description: 'Kiosk not found' '500': description: 'Error deleting kiosk' security: - ApiKeyAuth: [] '/kiosk/api/devices/{kioskId}/name': post: tags: - Kiosk summary: 'Rename a kiosk device' operationId: renameKiosk parameters: - name: kioskId in: path required: true schema: type: integer requestBody: required: true content: application/json: schema: required: - name properties: name: type: string type: object responses: '200': description: 'Kiosk renamed' '400': description: 'Invalid request' '401': description: Unauthorized '403': description: 'Forbidden — ManageGroups role required' '404': description: 'Kiosk not found' security: - ApiKeyAuth: [] /plugins/api/plugins: get: tags: - Plugins summary: 'List all available plugins' operationId: listPlugins responses: '200': description: 'Plugin list' content: application/json: schema: properties: success: { type: boolean } data: { type: array, items: { type: object } } type: object '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '500': description: 'Error listing plugins' security: - ApiKeyAuth: [] '/plugins/api/plugins/{pluginId}': get: tags: - Plugins summary: 'Get plugin details' operationId: getPlugin parameters: - name: pluginId in: path required: true schema: type: string responses: '200': description: 'Plugin details' content: application/json: schema: properties: success: { type: boolean } data: { properties: { metadata: { type: object }, isActive: { type: boolean }, isConfigured: { type: boolean } }, type: object } type: object '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '404': description: 'Plugin not found' security: - ApiKeyAuth: [] delete: tags: - Plugins summary: 'Delete a community plugin from disk' description: 'Disables the plugin, calls its uninstall() lifecycle hook, deletes the community directory, and clears every plugin.{id}.* config key. Refuses to touch core plugins.' operationId: uninstallPlugin parameters: - name: pluginId in: path required: true schema: type: string responses: '200': description: 'Plugin removed' '400': description: 'Refused — core plugin or invalid id' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '500': description: 'Failed to uninstall' security: - ApiKeyAuth: [] '/plugins/api/plugins/{pluginId}/enable': post: tags: - Plugins summary: 'Enable a plugin' operationId: enablePlugin parameters: - name: pluginId in: path required: true schema: type: string responses: '200': description: 'Plugin enabled' '400': description: 'Plugin cannot be enabled (dependency or version issue)' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '500': description: 'Error enabling plugin' security: - ApiKeyAuth: [] '/plugins/api/plugins/{pluginId}/disable': post: tags: - Plugins summary: 'Disable a plugin' operationId: disablePlugin parameters: - name: pluginId in: path required: true schema: type: string responses: '200': description: 'Plugin disabled' '400': description: 'Plugin cannot be disabled (other plugins depend on it)' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '500': description: 'Error disabling plugin' security: - ApiKeyAuth: [] '/plugins/api/plugins/{pluginId}/settings': post: tags: - Plugins summary: 'Update plugin settings' operationId: updatePluginSettings parameters: - name: pluginId in: path required: true schema: type: string requestBody: required: true content: application/json: schema: properties: settings: description: 'Key-value map of settings to update' type: object type: object responses: '200': description: 'Settings updated' '400': description: 'No settings provided or plugin not found' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '404': description: 'Plugin not found' security: - ApiKeyAuth: [] '/plugins/api/plugins/{pluginId}/test': post: tags: - Plugins summary: 'Test plugin connection with provided settings' description: 'Password fields may be omitted — the plugin falls back to its saved value.' operationId: testPluginConnection parameters: - name: pluginId in: path required: true schema: type: string requestBody: required: false content: application/json: schema: properties: settings: description: 'Settings to test with (partial override)' type: object type: object responses: '200': description: 'Connection successful' '400': description: 'Connection test failed' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '404': description: 'Plugin not found or not active' security: - ApiKeyAuth: [] '/plugins/api/plugins/{pluginId}/reset': post: tags: - Plugins summary: 'Reset all settings for a plugin' operationId: resetPluginSettings parameters: - name: pluginId in: path required: true schema: type: string responses: '200': description: 'Settings reset' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '404': description: 'Plugin not found' security: - ApiKeyAuth: [] /plugins/api/approved: get: tags: - Plugins summary: 'List community plugins approved for URL install' description: 'Returns the entries from src/plugins/approved-plugins.json. Only plugins in this list can be installed by URL.' operationId: listApprovedPlugins responses: '200': description: 'Approved plugin list' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' security: - ApiKeyAuth: [] /plugins/api/plugins/install: post: tags: - Plugins summary: 'Install a community plugin from an approved download URL' description: 'Downloads the zip, verifies its SHA-256 against approved-plugins.json, validates the archive, and extracts it into src/plugins/community/{id}. The plugin is NOT enabled automatically — admins must review and click Enable.' operationId: installPluginFromUrl requestBody: required: true content: application/json: schema: properties: downloadUrl: description: 'HTTPS URL to the plugin zip. Must match an approved entry exactly.' type: string type: object responses: '200': description: 'Plugin installed (not yet enabled)' '400': description: 'Validation failure (unknown URL, checksum mismatch, unsafe zip)' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '409': description: 'Plugin already installed' '500': description: 'Install failed' security: - ApiKeyAuth: [] /plugins/api/plugins/install-url: post: tags: - Plugins summary: 'Install a community plugin from an arbitrary URL (unverified)' description: 'Installs a plugin from a URL that is NOT on the approved list. The admin must supply the expected SHA-256 themselves. The plugin is flagged as unverified and the admin UI shows a warning banner before it can be enabled. Intended for plugin developers testing their own builds and for admins running experimental plugins.' operationId: installPluginFromUnverifiedUrl requestBody: required: true content: application/json: schema: properties: downloadUrl: description: 'HTTPS URL to the plugin zip' type: string sha256: description: '64-hex SHA-256 of the zip bytes' type: string pluginId: description: 'Expected plugin id (kebab-case, must match top-level directory)' type: string type: object responses: '200': description: 'Plugin installed (unverified, not yet enabled)' '400': description: 'Validation failure' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '409': description: 'Plugin already installed' security: - ApiKeyAuth: [] '/plugins/api/plugins/{pluginId}/quarantine': delete: tags: - Plugins summary: "Clear a plugin's quarantine flag" description: 'Removes the quarantine flag from a plugin so it can be enabled again. Use after the underlying issue (crash, revoked registry entry, etc.) has been resolved.' operationId: clearPluginQuarantine parameters: - name: pluginId in: path required: true schema: type: string responses: '200': description: 'Quarantine cleared' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' security: - ApiKeyAuth: [] /plugins/api/registry/refresh: post: tags: - Plugins summary: 'Re-fetch the remote approved-plugin registry' description: 'Pulls the latest approved-plugins.json from the remote source and updates the session cache. Returns the merged list immediately so the UI can refresh without a page reload.' operationId: refreshPluginRegistry responses: '200': description: 'Registry refreshed — returns current approved list' '401': description: Unauthorized '403': description: 'Forbidden — Admin role required' '500': description: 'Refresh failed' security: - ApiKeyAuth: [] components: schemas: PortalCalendarSubscription: properties: title: description: 'The name a calendar app shows for the feed' type: string example: 'Main St. Cathedral Calendar' url: type: string example: 'https://church.example/api/public/portal-calendar/ab12…/calendar.ics' nullable: true webcalUrl: type: string example: 'webcal://church.example/api/public/portal-calendar/ab12…/calendar.ics' nullable: true choices: type: array items: properties: id: type: string example: 'calendar:3' name: type: string example: 'Public Calendar' color: type: string example: '#2c3e50' selected: type: boolean type: object type: object securitySchemes: ApiKeyAuth: type: apiKey description: 'API key obtained from Profile → API Key in ChurchCRM, or via POST /public/user/login' name: x-api-key in: header tags: - name: Calendar description: 'Calendar and event management' - name: People description: 'Person records' - name: Families description: 'Family records' - name: Groups description: 'Group management' - name: Properties description: 'Custom person and family properties' - name: Finance description: 'Deposits and payments (Finance role required)' - name: Users description: 'User settings and API key management' - name: 2FA description: 'Two-factor authentication setup' - name: System description: 'System configuration and notifications' - name: Admin description: 'Administration operations (Admin role required)' - name: Cart description: 'Selection cart for bulk operations' - name: Search description: 'Global search' - name: Map description: 'Geographic map data' - name: Kiosk description: 'Kiosk device management (Admin role required)' - name: Plugins description: 'Plugin management (Admin role required)' - name: Volunteer description: 'Volunteer Management v2 (requires the sVolunteerVersion rollout state to include v2)' - name: Email description: Email - name: 'Member Portal' description: 'Member Portal' - name: Import description: Import