Skip to content

Attendance API

QR code token generation and sign-in validation workflow.


Overview

The Attendance feature provides self-service check-in for rehearsals and performances:

  • QR Code Generation — Unique tokens for each event
  • Sign-in Validation — Conflict detection and late arrival handling
  • AEA Compliance — Automatic break tracking for union productions

Data Model

typescript
interface AttendanceToken {
    id: string;
    eventId: string;           // Scheduler event ID
    projectId: string;
    createdAt: Timestamp;
    expiresAt: Timestamp;      // Token validity window
    active: boolean;
}

interface AttendanceRecord {
    id: string;
    eventId: string;
    personnelId: PersonnelId;
    signInTime: Timestamp;
    signInMethod: 'qr' | 'manual';
    status: 'on-time' | 'late' | 'absent';
    notes?: string;
}

Token Generation

Create Event Token

typescript
import { httpsCallable } from 'firebase/functions';
import { functions } from '@/lib/firebase';

export const generateAttendanceToken = async (
    eventId: string,
    validityMinutes: number = 30
): Promise<AttendanceToken> => {
    const createToken = httpsCallable(functions, 'createAttendanceToken');
    
    const result = await createToken({
        eventId,
        validityMinutes,
    });
    
    return result.data as AttendanceToken;
};

Firebase Function (Server)

typescript
// functions/src/attendance.ts
import { onCall } from 'firebase-functions/v2/https';

export const createAttendanceToken = onCall(async (request) => {
    const { eventId, validityMinutes } = request.data;
    const userId = request.auth?.uid;
    
    // Verify user has permission to create tokens
    const member = await getMemberByUserId(userId);
    if (!member || !hasPermission(member, Permission.MANAGE_ATTENDANCE)) {
        throw new Error('Insufficient permissions');
    }
    
    const token: AttendanceToken = {
        id: crypto.randomUUID(),
        eventId,
        projectId: member.projectId,
        createdAt: Timestamp.now(),
        expiresAt: Timestamp.fromMillis(Date.now() + validityMinutes * 60 * 1000),
        active: true,
    };
    
    await db.collection('attendanceTokens').doc(token.id).set(token);
    
    return token;
});

QR Code Display

typescript
import QRCode from 'qrcode';

export const generateQRDataUrl = async (tokenId: string): Promise<string> => {
    const signInUrl = `${window.location.origin}/sign-in/${tokenId}`;
    return QRCode.toDataURL(signInUrl);
};

Sign-In Validation

Client-Side Flow

typescript
export const signIn = async (tokenId: string): Promise<SignInResult> => {
    const validateSignIn = httpsCallable(functions, 'validateAttendanceSignIn');
    
    const result = await validateSignIn({ tokenId });
    
    return result.data as SignInResult;
};

Server-Side Validation

typescript
// functions/src/attendance.ts
export const validateAttendanceSignIn = onCall(async (request) => {
    const { tokenId } = request.data;
    const userId = request.auth?.uid;
    
    // 1. Validate token exists and is active
    const tokenDoc = await db.collection('attendanceTokens').doc(tokenId).get();
    if (!tokenDoc.exists) {
        return { success: false, error: 'TOKEN_NOT_FOUND' };
    }
    
    const token = tokenDoc.data() as AttendanceToken;
    
    // 2. Check token expiration
    if (token.expiresAt.toMillis() < Date.now()) {
        return { success: false, error: 'TOKEN_EXPIRED' };
    }
    
    // 3. Get personnel linked to user
    const personnel = await getPersonnelByUserId(token.projectId, userId);
    if (!personnel) {
        return { success: false, error: 'NOT_LINKED' };
    }
    
    // 4. Check for duplicate sign-in
    const existingRecord = await db
        .collection('attendance')
        .where('eventId', '==', token.eventId)
        .where('personnelId', '==', personnel.id)
        .limit(1)
        .get();
    
    if (!existingRecord.empty) {
        return { success: false, error: 'ALREADY_SIGNED_IN' };
    }
    
    // 5. Determine on-time vs late
    const event = await getEvent(token.eventId);
    const status = determineStatus(event.startTime);
    
    // 6. Create attendance record
    const record: AttendanceRecord = {
        id: crypto.randomUUID(),
        eventId: token.eventId,
        personnelId: personnel.id,
        signInTime: Timestamp.now(),
        signInMethod: 'qr',
        status,
    };
    
    await db.collection('attendance').doc(record.id).set(record);
    
    return { success: true, record };
});

Late Arrival Handling

typescript
const determineStatus = (eventStart: Timestamp): AttendanceStatus => {
    const now = Date.now();
    const startMs = eventStart.toMillis();
    const gracePeriodMs = 5 * 60 * 1000; // 5 minutes
    
    if (now <= startMs + gracePeriodMs) {
        return 'on-time';
    }
    return 'late';
};

Manual Entry

For stage managers to add attendance records without QR:

typescript
export const manualSignIn = async (
    eventId: string,
    personnelId: PersonnelId,
    status: AttendanceStatus,
    notes?: string
): Promise<AttendanceRecord> => {
    const record: AttendanceRecord = {
        id: crypto.randomUUID(),
        eventId,
        personnelId,
        signInTime: Timestamp.now(),
        signInMethod: 'manual',
        status,
        notes,
    };
    
    await setDoc(doc(db, 'attendance', record.id), record);
    
    return record;
};

Conflict Detection

Prevent double-booking conflicts:

typescript
export const checkConflicts = async (
    personnelId: PersonnelId,
    eventId: string
): Promise<ConflictResult> => {
    // Get event details
    const event = await getEvent(eventId);
    
    // Check for overlapping attendance
    const conflicts = await db
        .collection('attendance')
        .where('personnelId', '==', personnelId)
        .where('signInTime', '>=', event.startTime)
        .where('signInTime', '<=', event.endTime)
        .get();
    
    if (!conflicts.empty) {
        return {
            hasConflict: true,
            conflictingEvents: conflicts.docs.map(d => d.data().eventId),
        };
    }
    
    return { hasConflict: false };
};

Error Codes

CodeDescriptionUser Message
TOKEN_NOT_FOUNDToken ID doesn't exist"Invalid QR code"
TOKEN_EXPIREDPast validity window"This check-in code has expired"
NOT_LINKEDUser not linked to personnel"Your account is not linked to this production"
ALREADY_SIGNED_INDuplicate sign-in attempt"You're already checked in"
PERMISSION_DENIEDUser lacks permissions"You don't have permission"

Last updated: January 2026