Skip to main content

Activity Log

The activity log provides an audit trail of all changes to tee times, slots, and bookings. This enables compliance tracking, dispute resolution, and operational visibility.

Architecture

Domain Services

├─► SlotChangeLog (slot-level changes)

└─► BookingHistory (booking-level changes)


TeeTimeActivityService


Merged Activity Feed

Source: libs/tee-time-services/src/lib/activity-log/tee-time-activity.service.ts

Data Models

SlotChangeLog

Records changes to individual tee time slots:

interface SlotChangeLog {
id: string;
teeTimeId: string;
slotNumber: number; // 1-4
action: string; // 'slot.booked', 'slot.blocked', etc.
previousStatus?: string;
newStatus: string;
actionBy?: string; // User/system identifier
actionAt: Date;
reason?: string;
metadata?: JsonValue;
}

BookingHistory

Records changes to bookings:

interface BookingHistory {
id: string;
bookingId: string;
teeTimeId: string;
action: string; // 'booking.created', 'booking.cancelled', etc.
actionBy?: string;
actionAt: Date;
notes?: string;
metadata?: JsonValue;
}

Action Types

Slot Actions

ActionTriggerLogged Data
slot.availableSlot releasedPrevious status
slot.heldHold placedHold expiry, holder ID
slot.bookedBooking confirmedBooking ID, player info
slot.blockedAdmin blockedBlock reason, block ID
slot.releasedHold/block removedRelease reason

Booking Actions

ActionTriggerLogged Data
booking.createdNew bookingPlayer, price, slot
booking.confirmedPayment completeTransaction ref
booking.cancelledCancellationCancel reason, refund
booking.rescheduledTime changeOld/new slot
booking.modifiedDetails changedChanged fields
booking.checked-inCheck-inCheck-in time, staff
booking.no-showNo-show markedStaff, time

TeeTimeActivityService

Merges slot and booking logs for a unified activity feed:

@Injectable()
export class TeeTimeActivityService {
async listForTeeTime(
courseId: string,
teeTimeId: string,
limit = 50
): Promise<TeeTimeActivityEntry[]> {
// Verify tee time belongs to course
const teeTime = await this.prisma.teeTime.findUnique({
where: { id: teeTimeId },
include: { teeSheet: { select: { courseId: true } } }
});

if (!teeTime || teeTime.teeSheet?.courseId !== courseId) {
throw new NotFoundException('Tee time not found for course');
}

// Fetch both log types in parallel
const [slotLogs, bookingLogs] = await Promise.all([
this.prisma.slotChangeLog.findMany({
where: { teeTimeId },
orderBy: { actionAt: 'desc' },
take: limit
}),
this.prisma.bookingHistory.findMany({
where: { teeTimeId },
orderBy: { actionAt: 'desc' },
take: limit
})
]);

// Merge and sort by timestamp (newest first)
return this.mergeAndSort(slotLogs, bookingLogs, limit);
}
}

Activity Entry Format

The merged activity feed uses a unified format:

interface TeeTimeActivityEntry {
id: string;
entityType: 'slot' | 'booking';
action: string;
actor: string | null; // Who performed the action
at: Date; // When it happened
slotNumber?: number; // For slot events
bookingId?: string; // For booking events
reason?: string; // Optional context
}

API Endpoint

// GET /admin/courses/:courseId/tee-times/:teeTimeId/activity?limit=50

// Response
[
{
"id": "log-1",
"entityType": "booking",
"action": "booking.created",
"actor": "online",
"at": "2025-12-15T07:00:00.000Z",
"bookingId": "booking-123"
},
{
"id": "log-2",
"entityType": "slot",
"action": "slot.blocked",
"actor": "admin@club.com",
"at": "2025-12-15T06:00:00.000Z",
"slotNumber": 2,
"reason": "Maintenance"
}
]

Admin UI Integration

Slot Drawer Activity Section

The TeeSheetDashboard displays activity when a slot is clicked:

// In TeeSheetDashboard.tsx
const [activityLog, setActivityLog] = useState<TeeTimeActivityEntry[]>([]);

// Fetch on slot click
useEffect(() => {
if (selectedTeeTime) {
fetch(`/admin/courses/${courseId}/tee-times/${selectedTeeTime.id}/activity`)
.then(res => res.json())
.then(setActivityLog);
}
}, [selectedTeeTime]);

// Render in drawer
<div data-testid="slot-activity">
<h4>Activity Log</h4>
{activityLog.map(entry => (
<div key={entry.id} className="activity-entry">
<span className="action">{entry.action}</span>
<span className="actor">{entry.actor}</span>
<span className="time">{formatTime(entry.at)}</span>
{entry.reason && <span className="reason">{entry.reason}</span>}
</div>
))}
</div>

Activity Entry Styling

.activity-entry {
display: flex;
gap: 8px;
padding: 8px 0;
border-bottom: 1px solid #f0f0f0;
}

.activity-entry .action {
font-weight: 500;
color: #1890ff;
}

.activity-entry .actor {
color: #666;
}

.activity-entry .time {
color: #999;
font-size: 12px;
}

.activity-entry .reason {
font-style: italic;
color: #666;
}

Logging Events

Services log activity at key points:

Booking Creation

// In BookingService
async createBooking(data: CreateBookingDto): Promise<Booking> {
const booking = await this.bookingRepo.create(data);

await this.bookingHistoryRepo.create({
bookingId: booking.id,
teeTimeId: data.teeTimeId,
action: 'booking.created',
actionBy: data.createdBy ?? 'system',
actionAt: new Date(),
metadata: { playerCount: data.playerCount, priceCents: data.priceCents }
});

return booking;
}

Slot Blocking

// In BlockService
async blockSlot(slotId: string, reason: string, blockedBy: string): Promise<void> {
const slot = await this.slotRepo.findById(slotId);

await this.slotRepo.update(slotId, { status: 'BLOCKED' });

await this.slotChangeLogRepo.create({
teeTimeId: slot.teeTimeId,
slotNumber: slot.slotNumber,
action: 'slot.blocked',
previousStatus: slot.status,
newStatus: 'BLOCKED',
actionBy: blockedBy,
actionAt: new Date(),
reason
});
}

Query Patterns

Get All Activity for a Date

SELECT * FROM slot_change_log scl
JOIN tee_time tt ON scl.tee_time_id = tt.id
JOIN tee_sheet ts ON tt.tee_sheet_id = ts.id
WHERE ts.course_id = :courseId
AND ts.date = :date
ORDER BY scl.action_at DESC;

Get Activity by Actor

SELECT * FROM booking_history
WHERE action_by = :actorId
AND action_at >= :startDate
ORDER BY action_at DESC;

Count Actions by Type

SELECT action, COUNT(*) as count
FROM slot_change_log
WHERE tee_time_id IN (
SELECT id FROM tee_time WHERE tee_sheet_id = :teeSheetId
)
GROUP BY action;

Retention Policy

Activity logs are retained based on compliance requirements:

DataRetentionReason
Booking history7 yearsFinancial/tax compliance
Slot changes1 yearOperational auditing
Check-in logs90 daysOperational

Archival

Old logs are archived to cold storage:

# Monthly archival job
pnpm nx run events-worker:cli -- archive-logs --older-than 365d

Metrics

MetricDescription
activity_log_entries_totalTotal entries by type
activity_log_query_latencyQuery execution time
activity_log_storage_bytesStorage consumption

Troubleshooting

Missing Activity Entries

  1. Verify service is logging at action points
  2. Check transaction commits
  3. Review error logs for write failures

Slow Activity Queries

  1. Check indexes on tee_time_id, action_at
  2. Review query plans
  3. Consider pagination for large result sets

Inconsistent Actor Values

  1. Ensure actionBy is always passed
  2. Standardize actor formats (email vs ID)
  3. Add validation at log creation