Volunteer
Volunteer Management v2 (requires the sVolunteerVersion rollout state to include v2)
๐๏ธ The events a ministry owns, with their staffing and headcount (D24, D26)
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.
๐๏ธ Create a ministry's event or event series through core (D24)
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.
๐๏ธ Delete events this ministry owns (D28)
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).
๐๏ธ Staff new events on the ministry's schedules that already follow them (D33)
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.
๐๏ธ Who may fill a position on a schedule that is about to be created
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.
๐๏ธ The events this team's ministry owns for the team's class (D28)
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.
๐๏ธ List a ministry's volunteer schedules
List a ministry's volunteer schedules
๐๏ธ Create a volunteer schedule: which calendar events a team staffs
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.
๐๏ธ List one team's volunteer schedules
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).
๐๏ธ Read one volunteer schedule
Read one volunteer schedule
๐๏ธ Update a volunteer schedule
Update a volunteer schedule
๐๏ธ Delete a volunteer schedule and its generated occurrences
Delete a volunteer schedule and its generated occurrences
๐๏ธ Staff this event: one calendar event, one team, one occurrence
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.
๐๏ธ Who may fill a position on the occurrences this schedule is about to generate
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.
๐๏ธ Materialise this schedule's occurrences up to a date
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).
๐๏ธ List a schedule's template staffing requirements
List a schedule's template staffing requirements
๐๏ธ Create or update a template staffing requirement on a schedule
Upsert on the (schedule, position) unique key: re-posting the same position updates the counts on the existing row.
๐๏ธ This occurrence's effective staffing needs, and the positions it could need
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.
๐๏ธ Override a staffing requirement for one occurrence
'This week we need four, not two.' The override wins over the schedule's template for that position only.
๐๏ธ Drop this occurrence's overrides so it follows the schedule again
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.
๐๏ธ Set this occurrence's whole staffing plan, overriding the schedule's
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.
๐๏ธ Remove a staffing requirement
Remove a staffing requirement
๐๏ธ List occurrences inside a date window, scoped to the caller
`from` and `to` are mandatory (design M9: no pagination protocol is invented for one module) and the result set is hard-capped.
๐๏ธ Read one occurrence with its effective times and effective requirements
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.
๐๏ธ Delete one occurrence and everything under it
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.
๐๏ธ Cancel or restore one occurrence without touching its schedule
Cancelling is not deleting: the row survives so a later generation run does not recreate it.
๐๏ธ The event titles a schedule may be narrowed to
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.
๐๏ธ The active calendar event types a schedule may follow
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.
๐๏ธ The groups a class schedule may follow
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.
๐๏ธ Search upcoming calendar events to staff
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.
๐๏ธ Everything the coordinator dashboard needs, in one call
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.
๐๏ธ My own volunteer commitments
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.
๐๏ธ Accept or decline my own assignment
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.
๐๏ธ Propose a substitute who has already agreed
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.
๐๏ธ Withdraw my own pending substitution request
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.
๐๏ธ Open slots I am qualified for and could sign up to right now
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.
๐๏ธ Put myself on an open slot
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.
๐๏ธ What I am qualified to do
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).
๐๏ธ Who I may offer as my substitute
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.
๐๏ธ Ministries that are asking for help
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.
๐๏ธ Tell a ministry you would like to help
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.
๐๏ธ Requirements, live/gap counts and assignment rows for one occurrence
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.
๐๏ธ Who may be assigned to a position on this occurrence
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.
๐๏ธ Assign a qualified person to a position on this occurrence
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.
๐๏ธ One assignment with its full response history
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.
๐๏ธ Cancel an assignment (hard-deletes only a pending row with no history)
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.
๐๏ธ Change an assignment's status, or record a response on the volunteer's behalf
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.
๐๏ธ Re-enqueue the assignment message for this volunteer
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.
๐๏ธ The notification outbox rows for one assignment
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.
๐๏ธ Unfilled staffing requirements across the caller's scope
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).
๐๏ธ The substitution queue for the caller's scope
The substitution queue for the caller's scope
๐๏ธ Approve a proposed substitution
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.
๐๏ธ Reject a proposed substitution
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.
๐๏ธ List volunteer coordinator and team-leader scope grants
List volunteer coordinator and team-leader scope grants
๐๏ธ Grant ministry-coordinator or team-leader authority to a person
Grant ministry-coordinator or team-leader authority to a person
๐๏ธ Revoke a volunteer coordinator or team-leader scope grant
Revoke a volunteer coordinator or team-leader scope grant
๐๏ธ What volunteer structure may the authenticated person manage?
What volunteer structure may the authenticated person manage?
๐๏ธ List the volunteer ministries the caller may administer
List the volunteer ministries the caller may administer
๐๏ธ Create a volunteer ministry
Global volunteer managers and administrators only (design ยง4.6).
๐๏ธ One ministry with its teams and positions
One ministry with its teams and positions
๐๏ธ Update a ministry's name, description or active flag
Update a ministry's name, description or active flag
๐๏ธ Delete a deactivated ministry and everything under it
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).
๐๏ธ The three counts the ministry overview shows
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.
๐๏ธ Teams inside a ministry
Teams inside a ministry
๐๏ธ Create a team inside a ministry
Create a team inside a ministry
๐๏ธ Sunday School classes a team of this ministry may be linked to (D23)
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).
๐๏ธ One team with the positions scoped to it
One team with the positions scoped to it
๐๏ธ Update a team's name, description or active flag
Update a team's name, description or active flag
๐๏ธ Delete a team and everything under it
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.
๐๏ธ Positions of a ministry
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).
๐ ๏ธ One position
One position
๐๏ธ Delete a position and everything that references it
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).
๐๏ธ The people in this ministry's volunteer pool
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.
๐๏ธ Put a person in this ministry's volunteer pool
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.
๐๏ธ Take a person out of this ministry's volunteer pool
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.
๐๏ธ Put everyone in the session cart into this ministry's volunteer pool
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.
๐๏ธ Take one person out of a ministry entirely
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.
๐๏ธ The rows of this ministry's qualification matrix
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).
๐๏ธ The people in one team's volunteer pools
The team's own pools UNION the parent ministry's pools, because a ministry-wide pool feeds every team under it.
๐๏ธ Everything the qualification matrix needs, in one response
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.
๐๏ธ One team's qualification matrix, in one response
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).
๐๏ธ Who is qualified for a position
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.
๐๏ธ Qualify a person for a position
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).
๐๏ธ Revoke a qualification by deactivating it
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).
๐๏ธ Everything one person is qualified for, scoped to the caller
A global manager sees every ministry; a coordinator sees only their own, and the narrowing happens in the query (design section 4.4).
๐๏ธ Report the Volunteer Management rollout state
Report the Volunteer Management rollout state
Can't find what you need? Ask in Discord, or open an issue on the ChurchCRM repository.