Skip to the content.

← Back to Gatherings System

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:

Other Staff:

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

Foreign Keys

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:

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:

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:

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:

  1. Verify gathering exists and user has edit permission
  2. Create new staff entity
  3. Handle form submission with autocomplete data transformation:
    • If member_public_id is empty but member_sca_name has value, transform to sca_name
    • If member_public_id is set, look up member_id from public ID
  4. Auto-assign sort_order based on is_steward
  5. 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:

  1. Load staff member with gathering
  2. Verify user has edit permission on gathering
  3. Handle form submission
  4. Save changes (member_id and sca_name are frozen)
  5. Redirect

Field Restrictions: Cannot change member_id or sca_name after creation to maintain data integrity.

delete($id)

Soft delete staff member.

Process:

  1. Load staff member
  2. Verify user has edit permission on gathering
  3. Soft delete (sets deleted timestamp)
  4. 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:

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:

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:

  1. 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
  2. When AMP member selected from autocomplete:
    • member_public_id hidden field populated
    • member_sca_name displays selected member’s name
    • If steward, AJAX call to getMemberContactInfo() fetches email/phone
    • Success notice shown when contact info auto-fills
  3. When custom name entered (not from autocomplete):
    • member_public_id remains empty
    • member_sca_name contains the typed custom SCA name
    • No auto-fill of contact information
    • Controller transforms member_sca_namesca_name for database
  4. 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:

  1. Create gathering
  2. Add staff tab
  3. 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:

  1. Add first steward (as above)
  2. 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:

  1. Add 2 stewards (AMP members, contact required)
  2. Add Herald (AMP member, no contact - not steward)
  3. Add List Master (non-AMP, SCA name: “Robert of Somewhere”)
  4. 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:

  1. Enable gathering public page
  2. Set steward show_on_public_page = true
  3. 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:

Test Cases

File: tests/TestCase/Model/Table/GatheringStaffTableTest.php

Coverage:

Integration Testing

Manual Test Plan:

  1. Add steward → verify contact auto-population
  2. Add non-AMP staff → verify XOR validation
  3. Try to save steward without contact → verify error
  4. Edit steward contact → verify changes persist
  5. Delete staff → verify soft delete
  6. 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:

Contact Info Not Auto-Filling

Symptom: Email/phone fields stay empty when selecting member.

Possible Causes:

  1. Member doesn’t have email/phone in their account
  2. JavaScript error preventing AJAX call
  3. Not marked as steward (only stewards auto-populate)

Solutions:

  1. Check member’s account for contact info
  2. Check browser console for JavaScript errors
  3. 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

  1. Always Assign Stewards: Every gathering should have at least one steward.

  2. Privacy First: Use contact notes to document preferences:
    • “Text only - no calls”
    • “Available 9 AM - 5 PM”
    • “Event contact only”
  3. Public Page Considerations:
    • Only set show_on_public_page = true with permission
    • Use event-specific contact info for public display
    • Consider privacy implications
  4. 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
  5. Non-AMP Staff: Format SCA names consistently:
    • “Robert of Somewhere” vs. “robert”
    • Include titles if appropriate: “Lord Robert of Somewhere”

Quick Reference

Add Steward:

  1. Staff tab → “Add Staff Member”
  2. Check “This person is a Steward”
  3. Select AMP member
  4. Contact info auto-fills (editable)
  5. Add role and notes
  6. Save

Add Other Staff:

  1. Staff tab → “Add Staff Member”
  2. Leave “Steward” unchecked
  3. Select AMP member OR enter SCA name
  4. Add role (custom text)
  5. Optional: Add contact info
  6. Save

AJAX Endpoint:

GET /gathering-staff/get-member-contact-info?member_id=123
Response: {"email": "...", "phone": "..."}

Validation Rules: