Gathering Staff Management
Last Updated: November 4, 2025
Status: Active
Controller: GatheringStaffController
Models: GatheringStaffTable, GatheringStaff entity
Overview
The gathering staff management system allows gatherings to have multiple staff members with customizable roles. Staff members are categorized into stewards (primary event contacts) and other staff (supporting roles), with flexible handling for both AMP members and non-members.
Key Concepts
Staff Categories
Stewards:
- Primary event contacts
- Must be linked to AMP member accounts
- Required to have email OR phone contact information
- Contact info auto-populates from member account (editable)
- Displayed first in staff listings with badge
- Multiple stewards can be assigned to one event
Other Staff:
- Supporting roles (Herald, List Master, Water Bearer, etc.)
- Can be AMP members OR generic SCA names
- Contact information is optional
- Flexible for volunteers without AMP accounts
- Custom role names (free-text field)
Database Schema
Table: gathering_staff
| Column | Type | Constraints | Description |
|---|---|---|---|
id |
INT | PRIMARY KEY, AUTO_INCREMENT | Unique identifier |
gathering_id |
INT | NOT NULL, FK → gatherings.id | Parent gathering |
member_id |
INT | NULLABLE, FK → members.id | AMP member (if applicable) |
sca_name |
VARCHAR(255) | NULLABLE | SCA name for non-AMP staff |
role |
VARCHAR(100) | NOT NULL | Role name |
is_steward |
BOOLEAN | NOT NULL, DEFAULT FALSE | Steward flag |
email |
VARCHAR(255) | NULLABLE | Contact email |
phone |
VARCHAR(50) | NULLABLE | Contact phone |
contact_notes |
TEXT | NULLABLE | Contact preferences |
sort_order |
INT | NOT NULL, DEFAULT 0 | Display ordering |
show_on_public_page |
BOOLEAN | NOT NULL, DEFAULT FALSE | Public page visibility |
created |
DATETIME | Creation timestamp | |
modified |
DATETIME | Last modification timestamp | |
created_by |
INT | User who created record | |
modified_by |
INT | User who last modified record | |
deleted |
DATETIME | NULLABLE | Soft delete marker |
Indexes
- PRIMARY KEY (
id) - INDEX (
gathering_id) - INDEX (
member_id) - INDEX (
is_steward) - INDEX (
sort_order) - INDEX (
deleted)
Foreign Keys
gathering_id→gatherings.id(CASCADE on delete)member_id→members.id(NO ACTION on delete)
Business Rules
XOR Rule: Member ID vs. SCA Name
Constraint: Must have EITHER member_id OR sca_name, not both, not neither.
Validation in Model:
// In GatheringStaffTable::buildRules()
$rules->add(function ($entity, $options) {
$hasMemberId = !empty($entity->member_id);
$hasScaName = !empty($entity->sca_name);
// XOR: must have one but not both
return $hasMemberId xor $hasScaName;
}, 'xorMemberIdScaName', [
'errorField' => 'member_id',
'message' => 'Must have either member_id OR sca_name, not both.'
]);
Use Cases:
- AMP member staff: Set
member_id, leavesca_nameNULL - Non-AMP staff: Set
sca_name, leavemember_idNULL
Steward Contact Rule
Constraint: If is_steward = true, must have email OR phone (at least one).
Validation in Model:
$rules->add(function ($entity, $options) {
if ($entity->is_steward) {
return !empty($entity->email) || !empty($entity->phone);
}
return true;
}, 'stewardContact', [
'errorField' => 'email',
'message' => 'Stewards must have email or phone contact information.'
]);
Rationale: Attendees need to be able to reach stewards for event questions.
Contact Auto-Population
Behavior: When a steward with member_id is created, email and phone auto-fill from the member’s account.
Implementation:
// In GatheringStaffTable::beforeSave()
public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)
{
if ($entity->isNew() && $entity->is_steward && $entity->member_id) {
// Load member data
$member = $this->Members->get($entity->member_id);
// Auto-populate if not already set
if (empty($entity->email)) {
$entity->email = $member->email_address;
}
if (empty($entity->phone)) {
$entity->phone = $member->phone_number;
}
}
return true;
}
Privacy Override: Auto-populated values are editable, allowing stewards to use event-specific contact information.
Sort Ordering
Convention:
- Stewards:
sort_order0-99 - Other staff:
sort_order100+
Auto-Assignment:
// In GatheringStaffController::add()
if ($entity->is_steward) {
// Get max steward sort_order and add 1
$maxSort = $this->GatheringStaff->find()
->where([
'gathering_id' => $gatheringId,
'is_steward' => true
])
->max('sort_order') ?? -1;
$entity->sort_order = $maxSort + 1;
} else {
// Get max non-steward sort_order and add 1 (starting from 100)
$maxSort = $this->GatheringStaff->find()
->where([
'gathering_id' => $gatheringId,
'is_steward' => false
])
->max('sort_order') ?? 99;
$entity->sort_order = max(100, $maxSort + 1);
}
Display: Staff always displayed with stewards first, then by sort_order.
Model Layer
GatheringStaffTable
Location: src/Model/Table/GatheringStaffTable.php
Associations:
$this->belongsTo('Gatherings', [
'foreignKey' => 'gathering_id',
'joinType' => 'INNER'
]);
$this->belongsTo('Members', [
'foreignKey' => 'member_id',
'joinType' => 'LEFT'
]);
Behaviors:
Timestamp- Manages created/modified timestampsFootprint- Tracks created_by/modified_byTrash- Soft delete support
Custom Finders:
// Find all stewards for a gathering
public function findStewards(Query $query, array $options): Query
{
return $query->where(['is_steward' => true])
->orderBy(['sort_order' => 'ASC']);
}
// Find all non-steward staff
public function findOtherStaff(Query $query, array $options): Query
{
return $query->where(['is_steward' => false])
->orderBy(['sort_order' => 'ASC']);
}
GatheringStaff Entity
Location: src/Model/Entity/GatheringStaff.php
Virtual Fields:
protected function _getDisplayName(): string
{
if ($this->member) {
return $this->member->sca_name;
}
return $this->sca_name ?? 'Unknown';
}
protected function _getHasContactInfo(): bool
{
return !empty($this->email) || !empty($this->phone);
}
Accessible Fields:
protected array $_accessible = [
'gathering_id' => true,
'member_id' => true, // Frozen after creation
'sca_name' => true, // Frozen after creation
'role' => true,
'is_steward' => true,
'email' => true,
'phone' => true,
'contact_notes' => true,
'sort_order' => true,
'show_on_public_page' => true,
];
Controller Layer
GatheringStaffController
Location: src/Controller/GatheringStaffController.php
Actions:
add($gatheringId)
Add new staff member to a gathering.
Process:
- Verify gathering exists and user has edit permission
- Create new staff entity
- Handle form submission with autocomplete data transformation:
- If
member_public_idis empty butmember_sca_namehas value, transform tosca_name - If
member_public_idis set, look upmember_idfrom public ID
- If
- Auto-assign sort_order based on is_steward
- Save and redirect
Data Transformation:
public function add($gatheringId) {
if ($this->request->is('post')) {
$data = $this->request->getData();
// Handle autocomplete data
if (empty($data['member_public_id']) && !empty($data['member_sca_name'])) {
// Custom SCA name entered (not an AMP member)
$data['sca_name'] = $data['member_sca_name'];
unset($data['member_sca_name']);
unset($data['member_public_id']);
} elseif (!empty($data['member_public_id'])) {
// AMP member selected - look up member_id from public_id
$member = $this->fetchTable('Members')->find()
->where(['public_id' => $data['member_public_id']])
->first();
if ($member) {
$data['member_id'] = $member->id;
}
unset($data['member_public_id']);
unset($data['member_sca_name']);
}
$staff = $this->GatheringStaff->patchEntity($staff, $data);
// ... rest of save logic
}
}
Authorization: $this->Authorization->authorize($gathering, 'edit')
Authorization Skip: The getMemberContactInfo() helper method skips authorization as it’s an AJAX utility:
public function initialize(): void
{
parent::initialize();
$this->Authorization->skipAuthorization(['getMemberContactInfo']);
}
edit($id)
Edit existing staff member.
Process:
- Load staff member with gathering
- Verify user has edit permission on gathering
- Handle form submission
- Save changes (member_id and sca_name are frozen)
- Redirect
Field Restrictions: Cannot change member_id or sca_name after creation to maintain data integrity.
delete($id)
Soft delete staff member.
Process:
- Load staff member
- Verify user has edit permission on gathering
- Soft delete (sets
deletedtimestamp) - Redirect
getMemberContactInfo()
AJAX endpoint for contact info lookup.
Route: GET /gathering-staff/get-member-contact-info?member_id={id}
Response:
{
"email": "member@example.com",
"phone": "555-0123"
}
Usage: Called when user selects AMP member in add/edit modal to auto-populate contact fields.
View Layer
Staff Tab
Location: templates/element/gatherings/staffTab.php
Features:
- List of stewards (with badge)
- List of other staff
- “Add Staff Member” button
- Edit/Delete buttons per staff member
- Embedded modals for add/edit operations
Display:
Stewards
--------
🏅 Lord John Smith - Steward
Email: john@example.com
Phone: 555-0123
Notes: Prefer text messages
[Edit] [Delete]
Other Staff
-----------
Dame Sarah - Herald
[Edit] [Delete]
Robert (SCA) - List Master
Email: robert@example.com
[Edit] [Delete]
Add Staff Modal
Template: templates/element/gatherings/staffTab.php (embedded modal)
UI Pattern: Uses autocomplete control (same pattern as Awards plugin recommendations).
Fields:
- Steward checkbox - Marks as primary event contact
- Member/SCA Name (Autocomplete) - Single field that handles both:
- Type to search AMP members (autocomplete dropdown)
- Or enter any custom SCA name for non-AMP members
- Based on
autoCompleteControlelement withallowOtherValues = true
- Role text field - Custom role name (e.g., “Event Steward”, “Herald”, “List Master”)
- Email - Auto-populated for stewards from AMP member account (editable)
- Phone - Auto-populated for stewards from AMP member account (editable)
- Contact notes - Preferences and additional contact information
- Show on public page checkbox - Controls visibility on public landing page
Autocomplete Implementation:
<?php
$memberUrl = $this->Url->build([
'controller' => 'Members',
'action' => 'AutoComplete',
'plugin' => null
]);
echo $this->KMP->autoCompleteControl(
$this->Form,
'member_sca_name', // Text field name
'member_public_id', // Hidden ID field name
$memberUrl, // AJAX endpoint
__('AMP Member or SCA Name'),
true, // required
true, // allowOtherValues - key feature!
3, // minLength
[
'id' => 'add-member-autocomplete',
'data-action' => 'change->gathering-staff-add#memberSelected'
]
);
?>
JavaScript Behavior:
- When “Steward” is checked:
- Contact fields become required (at least email OR phone)
- If AMP member selected via autocomplete, contact info auto-fills via AJAX
- Steward requirement notice displayed
- When AMP member selected from autocomplete:
member_public_idhidden field populatedmember_sca_namedisplays selected member’s name- If steward, AJAX call to
getMemberContactInfo()fetches email/phone - Success notice shown when contact info auto-fills
- When custom name entered (not from autocomplete):
member_public_idremains emptymember_sca_namecontains the typed custom SCA name- No auto-fill of contact information
- Controller transforms
member_sca_name→sca_namefor database
- Autocomplete Events: ```javascript // Listen for autocomplete selection addAutocomplete.addEventListener(‘ac:selected’, function(event) { if (addIsStewardCheckbox.checked && event.detail && event.detail.id) { fetchMemberContactInfo(event.detail.id); } });
addAutocomplete.addEventListener(‘ac:cleared’, function() { autoFillNotice.style.display = ‘none’; });
**Data Flow:**
User Input → Autocomplete Component ↓ Option A: AMP Member Selected member_public_id = “abc123” member_sca_name = “Jane of Example” ↓ (if steward) AJAX → getMemberContactInfo() ↓ Auto-fill email/phone
Option B: Custom Name Entered member_public_id = (empty) member_sca_name = “John the Unknown” ↓ Controller Transform member_sca_name → sca_name (DB column)
**Benefits of Autocomplete Pattern:**
- **Consistent UX**: Matches Awards plugin pattern users are familiar with
- **Single field**: No field toggling between member dropdown and SCA name input
- **Type-ahead search**: Fast member lookup as you type (min 3 characters)
- **Flexible**: Works for both AMP members and non-members seamlessly
- **Better performance**: No need to load full member list on page load
- **User-friendly**: Clear instructions and helpful notices
### Edit Staff Modal
**Template:** `templates/GatheringStaff/edit.php`
**Similar to add modal but:**
- Member ID and SCA name fields are readonly/frozen
- Pre-populated with existing data
- Cannot switch between member and non-member after creation
## Authorization
### Policy: GatheringStaffPolicy
**Location:** `src/Policy/GatheringStaffPolicy.php`
**Rules:**
- `add`, `edit`, `delete`: User must have `edit` permission on parent gathering
- Permission check delegates to `GatheringPolicy::canEdit()`, which grants access to both branch-level permission holders **and** gathering stewards
**Implementation:**
```php
public function canAdd(IdentityInterface $user, GatheringStaff $staff)
{
$gathering = $this->fetchTable('Gatherings')->get($staff->gathering_id);
return $this->getAuthorization()->can($user, 'edit', $gathering);
}
Use Cases
Scenario 1: Single Steward Event
Setup:
- Create gathering
- Add staff tab
- Add staff member:
- Check “This person is a Steward”
- Select AMP member: “Lord John Smith”
- Email/phone auto-populate
- Role: “Event Steward”
- Save
Result: Steward appears first in staff list with badge and contact info.
Scenario 2: Co-Stewards with Privacy
Setup:
- Add first steward (as above)
- Add second steward:
- Select different AMP member
- Edit auto-populated email to event-specific address
- Add note: “Event contact only - do not share personal info”
- Check “Show on public page”
Result: Both stewards listed, second uses event-specific contact info on public page.
Scenario 3: Mixed Staff Roster
Setup:
- Add 2 stewards (AMP members, contact required)
- Add Herald (AMP member, no contact - not steward)
- Add List Master (non-AMP, SCA name: “Robert of Somewhere”)
- Add Water Bearers (non-AMP, SCA name: “Volunteers”, no contact)
Result: Comprehensive staff roster with varied privacy levels and account types.
Scenario 4: Public Event with Limited Contact Display
Setup:
- Enable gathering public page
- Set steward
show_on_public_page = true - Set other staff
show_on_public_page = false
Result: Public page shows only steward contact info, protecting privacy of supporting staff.
Testing
Fixtures
File: tests/Fixture/GatheringStaffFixture.php
Test Records:
- Mix of stewards and non-stewards
- AMP member and non-AMP entries
- Various sort orders
- Public/private display flags
Test Cases
File: tests/TestCase/Model/Table/GatheringStaffTableTest.php
Coverage:
- XOR validation (member_id vs. sca_name)
- Steward contact requirement
- Sort ordering logic
- Contact auto-population
- Soft delete behavior
Integration Testing
Manual Test Plan:
- Add steward → verify contact auto-population
- Add non-AMP staff → verify XOR validation
- Try to save steward without contact → verify error
- Edit steward contact → verify changes persist
- Delete staff → verify soft delete
- View public page → verify only opted-in staff appear
Troubleshooting
Cannot Save Steward Without Contact
Symptom: Validation error when trying to save steward.
Cause: Steward must have email OR phone.
Solution: Add at least one contact method.
Cannot Select Both Member and SCA Name
Symptom: Validation error about XOR constraint.
Cause: Must have either member_id OR sca_name, not both.
Solution: Choose one approach:
- For AMP members: Use member dropdown, leave SCA name blank
- For non-AMP: Enter SCA name, leave member dropdown empty
Contact Info Not Auto-Filling
Symptom: Email/phone fields stay empty when selecting member.
Possible Causes:
- Member doesn’t have email/phone in their account
- JavaScript error preventing AJAX call
- Not marked as steward (only stewards auto-populate)
Solutions:
- Check member’s account for contact info
- Check browser console for JavaScript errors
- Verify “Steward” checkbox is checked
Cannot Remove or Edit Member Assignment
Symptom: Cannot change member_id after initial save.
Cause: By design - member_id frozen after creation to maintain data integrity.
Solution: Delete staff member and re-add with correct member if needed.
Best Practices
-
Always Assign Stewards: Every gathering should have at least one steward.
- Privacy First: Use contact notes to document preferences:
- “Text only - no calls”
- “Available 9 AM - 5 PM”
- “Event contact only”
- Public Page Considerations:
- Only set
show_on_public_page = truewith permission - Use event-specific contact info for public display
- Consider privacy implications
- Only set
- Role Names: Use clear, descriptive roles:
- “Event Steward” vs. just “Steward”
- “Head Herald” vs. “Herald” for larger events
- “List Master - Armored Combat” for specific responsibilities
- Non-AMP Staff: Format SCA names consistently:
- “Robert of Somewhere” vs. “robert”
- Include titles if appropriate: “Lord Robert of Somewhere”
Related Documentation
- Gatherings System - Parent documentation
- Authorization Model - Permission system
- Model Behaviors - Trash, Footprint, Timestamp behaviors
Quick Reference
Add Steward:
- Staff tab → “Add Staff Member”
- Check “This person is a Steward”
- Select AMP member
- Contact info auto-fills (editable)
- Add role and notes
- Save
Add Other Staff:
- Staff tab → “Add Staff Member”
- Leave “Steward” unchecked
- Select AMP member OR enter SCA name
- Add role (custom text)
- Optional: Add contact info
- Save
AJAX Endpoint:
GET /gathering-staff/get-member-contact-info?member_id=123
Response: {"email": "...", "phone": "..."}
Validation Rules:
- XOR: member_id OR sca_name (not both, not neither)
- Steward: must have email OR phone
- Role: always required