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
| Code | Description | User Message |
|---|---|---|
TOKEN_NOT_FOUND | Token ID doesn't exist | "Invalid QR code" |
TOKEN_EXPIRED | Past validity window | "This check-in code has expired" |
NOT_LINKED | User not linked to personnel | "Your account is not linked to this production" |
ALREADY_SIGNED_IN | Duplicate sign-in attempt | "You're already checked in" |
PERMISSION_DENIED | User lacks permissions | "You don't have permission" |
Last updated: January 2026