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
| Action | Trigger | Logged Data |
|---|---|---|
slot.available | Slot released | Previous status |
slot.held | Hold placed | Hold expiry, holder ID |
slot.booked | Booking confirmed | Booking ID, player info |
slot.blocked | Admin blocked | Block reason, block ID |
slot.released | Hold/block removed | Release reason |
Booking Actions
| Action | Trigger | Logged Data |
|---|---|---|
booking.created | New booking | Player, price, slot |
booking.confirmed | Payment complete | Transaction ref |
booking.cancelled | Cancellation | Cancel reason, refund |
booking.rescheduled | Time change | Old/new slot |
booking.modified | Details changed | Changed fields |
booking.checked-in | Check-in | Check-in time, staff |
booking.no-show | No-show marked | Staff, 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:
| Data | Retention | Reason |
|---|---|---|
| Booking history | 7 years | Financial/tax compliance |
| Slot changes | 1 year | Operational auditing |
| Check-in logs | 90 days | Operational |
Archival
Old logs are archived to cold storage:
# Monthly archival job
pnpm nx run events-worker:cli -- archive-logs --older-than 365d
Metrics
| Metric | Description |
|---|---|
activity_log_entries_total | Total entries by type |
activity_log_query_latency | Query execution time |
activity_log_storage_bytes | Storage consumption |
Troubleshooting
Missing Activity Entries
- Verify service is logging at action points
- Check transaction commits
- Review error logs for write failures
Slow Activity Queries
- Check indexes on
tee_time_id,action_at - Review query plans
- Consider pagination for large result sets
Inconsistent Actor Values
- Ensure
actionByis always passed - Standardize actor formats (email vs ID)
- Add validation at log creation