Personnel Linking API
Module:
src/features/admin/api/personnelLinking.ts
Technical reference for the bidirectional user-to-personnel linking system.
Overview
The Personnel Linking API enables manual association between authenticated users (ProjectMember records) and personnel records (CastMember) stored in the cast data shard. This is used when automatic email-based linking fails or isn't appropriate.
Key Concepts
| Term | Description |
|---|---|
| Personnel | A CastMember record representing someone in the production (actor, crew, etc.) |
| User | An authenticated Firebase user with a ProjectMember record in the project |
| Linked | A user whose ProjectMember.linkedContactId points to a CastMember.id, and the CastMember has linkedUserId pointing back |
| Bidirectional | Both link directions must be maintained for proper association |
Functions
linkUserToPersonnel
Links a user to a personnel record with bidirectional updates.
async function linkUserToPersonnel(
projectId: string,
userId: string,
castMemberId: string
): Promise<void>Transaction Order:
- Read
castshard document - Read
members/{userId}document - Write updated cast array with
linkedUserId - Write updated member with
linkedContactId
Error Cases:
- Cast shard not found → Error thrown
- Personnel record not found → Error thrown
- Member document not found → Creates the field anyway (defensive)
unlinkUserFromPersonnel
Removes the bidirectional link between a user and personnel record.
async function unlinkUserFromPersonnel(
projectId: string,
userId: string,
castMemberId: string
): Promise<void>Behavior:
- Sets
CastMember.linkedUserIdtonull - Sets
ProjectMember.linkedContactIdtonull - Handles missing member document gracefully
getUnlinkedPersonnel
Returns personnel records that are not yet linked to any user.
async function getUnlinkedPersonnel(
projectId: string
): Promise<CastMember[]>Filters:
- Excludes soft-deleted personnel (
deleted: true) - Excludes already-linked personnel (
linkedUserIdis truthy)
getAllPersonnel
Returns all non-deleted personnel records.
async function getAllPersonnel(
projectId: string
): Promise<CastMember[]>getPersonnelById
Retrieves a single personnel record by ID.
async function getPersonnelById(
projectId: string,
personnelId: string
): Promise<CastMember | null>Helper Functions
normalizeCast
Handles corrupted cast data that may be stored as an object instead of array.
function normalizeCast(castData: unknown): CastMember[]Problem Solved:
Firestore field path updates like cast.0.linkedUserId can corrupt arrays into objects with numeric keys:
// Before (array):
{ cast: [{ id: 'a' }, { id: 'b' }] }
// After corrupted (object):
{ cast: { '0': { id: 'a' }, '1': { id: 'b' } } }This helper converts both formats back to a proper array.
Firestore Structure
Cast Shard Document
projects/{projectId}/data/cast
{
ownerId: string,
cast: CastMember[]
}CastMember Schema
interface CastMember {
id: string;
name: string;
type?: 'principal' | 'ensemble' | 'understudy' | 'crew' | 'other';
email?: string;
linkedUserId?: string | null; // ← Links to user
deleted?: boolean;
// ... other fields
}ProjectMember Schema
interface ProjectMember {
userId: string;
roleId: string;
status: 'pending' | 'active' | 'rejected';
linkedContactId?: string | null; // ← Links to CastMember
joinedAt: Timestamp;
}Transaction Ordering
Critical: All Firestore transaction reads must complete before any writes.
// ✅ CORRECT: All reads first
const castDoc = await transaction.get(castShardRef);
const memberDoc = await transaction.get(memberRef);
// Then writes...
transaction.update(castShardRef, { cast: updatedCast });
transaction.update(memberRef, { linkedContactId });
// ❌ WRONG: Read after write
const castDoc = await transaction.get(castShardRef);
transaction.update(castShardRef, {...}); // Write
const memberDoc = await transaction.get(memberRef); // Read after write - ERROR!Usage Example
import {
linkUserToPersonnel,
unlinkUserFromPersonnel,
getUnlinkedPersonnel
} from '../api/personnelLinking';
// Get available personnel to link
const available = await getUnlinkedPersonnel(projectId);
// Link a user to personnel
await linkUserToPersonnel(projectId, userId, personnelId);
// Unlink
await unlinkUserFromPersonnel(projectId, userId, personnelId);Org-Level Roster Linking
In addition to the project-level user↔personnel linking described above, On Book Pro supports org-level roster↔personnel linking that connects the organization's Company Roster to individual project personnel records.
Two Levels of Linking
| Level | Direction | Foreign Key | Purpose |
|---|---|---|---|
| Project | User ↔ Personnel | CastMember.linkedUserId, ProjectMember.linkedContactId | Links authenticated users to their personnel records within a project |
| Organization | Roster ↔ Personnel | CastMember.rosterPersonId | Links a project personnel record to an org-wide roster person |
Roster Linking API
Module:
src/features/admin/api/roster-sync-api.ts
// Link a roster person to a project personnel record
linkRosterToPerson(orgId: string, rosterPersonId: string, projectId: string, personnelId: string): Promise<void>
// Create a personnel record from a roster person (pre-filled fields)
buildPersonnelFromRoster(rosterPerson: RosterPerson): RosterPersonInputBidirectional Sync Cloud Functions
| Function | Trigger | Direction | Behavior |
|---|---|---|---|
onPersonnelWritten | Project personnel change | Upward (project → roster) | Detects field changes on roster-linked personnel. Contact fields (email, phone) sync directly. Contextual fields (bio, headshot) create PendingRosterUpdate documents for admin review. |
onRosterPersonWritten | Roster person change | Downward (roster → projects) | Propagates contact info changes to all linked project personnel records across all projects. |
Pending Updates Queue
When a project-level change affects a contextual field on a roster-linked person, a PendingRosterUpdate doc is created at organizations/{orgId}/pendingUpdates/{updateId}:
interface PendingRosterUpdate {
id: string;
rosterPersonId: string;
field: string; // e.g. "bio", "headshotUrl"
oldValue: string;
newValue: string;
sourceProjectId: string;
sourceProjectTitle: string;
sourcePersonnelId: string;
updatedBy: string;
createdAt: Date;
status: 'pending' | 'accepted' | 'dismissed';
}These are surfaced in the Admin Dashboard's Attention Required widget.
Related
- Cloud Sync Architecture — Firestore sharding patterns
- Permission System Usage — RBAC for admin operations
- Admin Portal — User-facing documentation
- AI Integration — Ask Barry contextual help pattern used in Roster CSV Import