Skip to content

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

TermDescription
PersonnelA CastMember record representing someone in the production (actor, crew, etc.)
UserAn authenticated Firebase user with a ProjectMember record in the project
LinkedA user whose ProjectMember.linkedContactId points to a CastMember.id, and the CastMember has linkedUserId pointing back
BidirectionalBoth link directions must be maintained for proper association

Functions

linkUserToPersonnel

Links a user to a personnel record with bidirectional updates.

typescript
async function linkUserToPersonnel(
    projectId: string,
    userId: string,
    castMemberId: string
): Promise<void>

Transaction Order:

  1. Read cast shard document
  2. Read members/{userId} document
  3. Write updated cast array with linkedUserId
  4. 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.

typescript
async function unlinkUserFromPersonnel(
    projectId: string,
    userId: string,
    castMemberId: string
): Promise<void>

Behavior:

  • Sets CastMember.linkedUserId to null
  • Sets ProjectMember.linkedContactId to null
  • Handles missing member document gracefully

getUnlinkedPersonnel

Returns personnel records that are not yet linked to any user.

typescript
async function getUnlinkedPersonnel(
    projectId: string
): Promise<CastMember[]>

Filters:

  • Excludes soft-deleted personnel (deleted: true)
  • Excludes already-linked personnel (linkedUserId is truthy)

getAllPersonnel

Returns all non-deleted personnel records.

typescript
async function getAllPersonnel(
    projectId: string
): Promise<CastMember[]>

getPersonnelById

Retrieves a single personnel record by ID.

typescript
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.

typescript
function normalizeCast(castData: unknown): CastMember[]

Problem Solved:

Firestore field path updates like cast.0.linkedUserId can corrupt arrays into objects with numeric keys:

javascript
// 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

typescript
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

typescript
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.

typescript
// ✅ 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

typescript
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

LevelDirectionForeign KeyPurpose
ProjectUser ↔ PersonnelCastMember.linkedUserId, ProjectMember.linkedContactIdLinks authenticated users to their personnel records within a project
OrganizationRoster ↔ PersonnelCastMember.rosterPersonIdLinks a project personnel record to an org-wide roster person

Roster Linking API

Module: src/features/admin/api/roster-sync-api.ts

typescript
// 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): RosterPersonInput

Bidirectional Sync Cloud Functions

FunctionTriggerDirectionBehavior
onPersonnelWrittenProject personnel changeUpward (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.
onRosterPersonWrittenRoster person changeDownward (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}:

typescript
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.