PHASE 5: Access Control & Resources

Trigger: "Execute Phase 5 as per masterplan."

Goal: Professional access control system (Gantner, OSDP, BLE, QR door), QR/NFC check-in, resource management with booking calendar, course scheduling, and utilization analytics.

Prerequisite: Phase 4 is completed.

Step 5.0 — Technical Conception

Create doc/developer/conception/phase-5-conception.md.

(reference: Chapter 11 — Data Model, Chapter 12 — Integration (Physical Access Control))

Content: - Scope: membership-checkin, membership-resource, membership-event (course part) - Hardware Abstraction: AccessControlAdapter interface with vendor-specific implementations - Protocol Analysis: Gantner REST API, OSDP v2 (SIA standard), BLE proximity, QR rotation - Credential Lifecycle: Provisioning -> active -> suspended -> revoked - Anti-Passback: Logic and edge cases - Resource Booking: Conflict detection algorithm, recurring events (iCalendar RRULE) - Design Chapter References: Ch11, Ch12 (Physical Access Control section)

Result: Technical conception for Phase 5.


Step 5.1 — Check-in Backend

Create the membership-checkin module core.

(reference: Chapter 11 — Data Model, Chapter 12 — Integration)

Approach:

  1. Create JPA entities: - CheckIn fields:
Field Type Constraints Description
id Long PK
entityId Long FK NOT NULL Organization
memberId Long FK NOT NULL Who checked in
zoneId Long FK nullable Access zone (null = main entrance)
methodCd String(20) NOT NULL QR, NFC, BLE, MANUAL, CARD
directionCd String(10) NOT NULL IN / OUT
statusCd String(20) NOT NULL GRANTED / DENIED
denialReason String(200) nullable e.g., "Contract expired", "Zone restricted"
credentialId Long FK nullable Credential used
deviceId String(100) nullable Reader/kiosk device identifier
checkedInAt Instant NOT NULL Timestamp
checkedOutAt Instant nullable Checkout timestamp (null = still in)
createdAt Instant auto
version Long @Version
  • AccessZone fields:
Field Type Constraints Description
id Long PK
entityId Long FK NOT NULL
name String(200) NOT NULL e.g., "Main Gym", "Sauna", "Locker Room A"
typeCd String(50) NOT NULL BUILDING, FLOOR, ROOM, AREA, LOCKER
parentZoneId Long FK nullable Hierarchical: building > floor > room
maxOccupancy Integer nullable Capacity limit (null = unlimited)
currentOccupancy Integer default 0 Live count (from check-in/out events)
requiresContractTier String(50) nullable Minimum template tier for access (null = any active contract)
active boolean default true
createdAt Instant auto
version Long @Version
  • AccessRule fields:
Field Type Constraints Description
id Long PK
entityId Long FK NOT NULL
zoneId Long FK NOT NULL Zone this rule applies to
name String(200) NOT NULL e.g., "Sauna premium only"
ruleCd String(50) NOT NULL MEMBERSHIP_TIER, TIME_RESTRICTION, DAY_OF_WEEK, BLACKLIST
config JSONB NOT NULL Rule-specific config (see below)
priority Integer default 0 Higher = evaluated first
active boolean default true
version Long @Version
  • Rule config examples:

    • MEMBERSHIP_TIER: {"allowedTemplateIds": [1, 3, 5]}
    • TIME_RESTRICTION: {"allowFrom": "06:00", "allowUntil": "22:00"}
    • DAY_OF_WEEK: {"allowedDays": ["MON", "TUE", "WED", "THU", "FRI"]}
    • BLACKLIST: {"blockedMemberIds": [42, 99]}
  • Credential fields:

Field Type Constraints Description
id Long PK
entityId Long FK NOT NULL
memberId Long FK NOT NULL
typeCd String(20) NOT NULL NFC_CARD, BLE_TOKEN, QR_CODE, RFID, WIEGAND
identifier String(255) NOT NULL Card number, token, QR payload
statusCd String(20) default 'ACTIVE' PROVISIONED / ACTIVE / SUSPENDED / REVOKED
externalId String(255) nullable ID in vendor system (Gantner, etc.)
issuedAt Instant NOT NULL
expiresAt Instant nullable
lastUsedAt Instant nullable
version Long @Version
  1. Create CheckInService: - processCheckIn(memberId, zoneId, method, credentialId):

    1. Validate member has active contract (not expired, not suspended)
    2. Evaluate AccessRules for zone (all active rules must pass)
    3. Check anti-passback (not already checked in to this zone)
    4. If occupancy-limited: check currentOccupancy < maxOccupancy
    5. Record CheckIn with GRANTED/DENIED status
    6. If GRANTED: increment zone occupancy, publish checkin.granted event (RabbitMQ)
    7. If DENIED: record denialReason, publish checkin.denied event - processCheckOut(memberId, zoneId): decrement occupancy, set checkedOutAt - getHistory(memberId, dateRange): paginated check-in history - getLiveFeed(entityId): real-time check-in stream (last 50 events) - Credential fallback chain: NFC → BLE → QR → manual lookup
  2. Create AccessControlService: - Zone CRUD with hierarchy validation - Rule CRUD with config validation per rule type - Credential provisioning: create → PROVISIONED → activate → ACTIVE → suspend/revoke - Real-time occupancy: getOccupancy(zoneId) → current count, max, percentage - Remote unlock: unlockDoor(zoneId, duration) → send command via HAL adapter (Step 5.2)

  3. Create controller: - POST /api/checkin — Process check-in (QR scan, NFC tap, manual) - POST /api/checkin/checkout — Process check-out - GET /api/checkin/history/{memberId} — Member check-in history - GET /api/checkin/live-feed — Real-time check-in events (admin) - GET /api/access/zones — List zones (hierarchical) - POST /api/access/zones — Create zone - GET /api/access/zones/{id}/occupancy — Current occupancy - POST /api/access/zones/{id}/unlock — Remote unlock (admin) - GET /api/access/rules — List rules for zone - POST /api/access/rules — Create rule - GET /api/access/credentials/{memberId} — List member's credentials - POST /api/access/credentials — Provision credential

  4. Create Flyway: V500__create_checkin.sql, V501__create_access_control.sql - Indexes: (entityId, memberId, checkedInAt), (zoneId, directionCd), (credentialId), (memberId, statusCd) on Credential

  5. Create kiosk-mode UI component (full-screen check-in interface for tablets at entrance)

  6. Tests: CheckInServiceTest (grant, deny, anti-passback, occupancy limit, rule evaluation), AccessControlServiceTest (zone hierarchy, credential lifecycle)

Result: Check-in system with QR/NFC/BLE, zone-based access control, access rules, and real-time occupancy.


Step 5.2 — HAL Integration (Hardware Abstraction Layer)

Implement the AccessControlAdapter interface with 5 vendor adapters.

(reference: Chapter 12 — Integration, Section: Physical Access Control)

Sub-Step 5.2a — HAL Interface and Gantner Essecca

  1. Define AccessControlAdapter interface: java public interface AccessControlAdapter { void provisionCredential(Credential credential); void revokeCredential(String credentialId); DoorCommandResult unlockDoor(String zoneId, Duration holdOpen); OccupancyInfo getOccupancy(String zoneId); List<AccessEvent> getRecentEvents(String zoneId, int limit); }
  2. GantnerAdapter: GAT REST API client for Essecca platform - Credential sync, zone assignment, door commands - Turnstile, door, and locker support - Credential provisioning flow: member -> Gantner card mapping
  3. Tests with WireMock for Gantner API

Sub-Step 5.2b — OSDP v2 Protocol

  1. OsdpAdapter: SIA OSDP v2 protocol implementation - OSDP gateway service (AES-128 encrypted communication) - Door controller abstraction - Replace legacy Wiegand where applicable
  2. Unit tests for OSDP message framing

Sub-Step 5.2c — BLE Mobile Credentials

  1. BleAdapter: Bluetooth Low Energy proximity unlock - Range configuration per zone (1m / 3m / 5m) - Battery and offline considerations - Flutter: flutter_blue_plus integration for mobile credential broadcast
  2. Tests for range validation logic

Sub-Step 5.2d — QR Door Access

  1. QrDoorAdapter: QR code posted at studio door - Member scans with phone camera -> API validates -> door unlocks (5s) - Time-limited token, anti-replay (QR rotated every 30 seconds by display device) - QR is immutable after generation (security requirement from Ch08)
  2. Tests for token validation and anti-replay

Sub-Step 5.2e — Generic Adapters

  1. GenericHttpAdapter: for any door system with HTTP/REST API
  2. SaltoAdapter, DormakabaAdapter: vendor stubs (implemented when needed)
  3. Adapter selection via @ConditionalOnProperty("membership.access.provider")

Result: 5 access control adapters behind unified AccessControlAdapter interface.


Step 5.3 — Resource CRUD

Create the membership-resource module.

(reference: Chapter 11 — Data Model)

Approach:

  1. Create JPA entities: - Resource fields:
Field Type Constraints Description
id Long PK
entityId Long FK NOT NULL
name String(200) NOT NULL e.g., "Studio A", "Tennis Court 1"
typeCd String(50) NOT NULL ROOM, AREA, EQUIPMENT, VEHICLE, PERSONNEL
description String(1000)
capacity Integer nullable Max people/users
locationDescription String(500) nullable Floor, building, address
zoneId Long FK nullable Link to AccessZone
imageUrl String(500) nullable Photo
maintenanceSchedule JSONB default '{}' Recurring maintenance windows
statusCd String(20) default 'AVAILABLE' AVAILABLE / IN_USE / MAINTENANCE / RETIRED
sortOrder Integer default 0
createdAt Instant auto
version Long @Version
  • ResourceBooking fields:
Field Type Constraints Description
id Long PK
resourceId Long FK NOT NULL
entityId Long FK NOT NULL
title String(200) NOT NULL Booking title
startDateTime Instant NOT NULL Start
endDateTime Instant NOT NULL End
recurrenceRule String(500) nullable iCalendar RRULE (recurring bookings)
bookedByUserId Long FK NOT NULL Who booked
bookedForType String(20) NOT NULL COURSE, EVENT, MEMBER, MAINTENANCE
bookedForId Long nullable Course/event/member ID
statusCd String(20) default 'CONFIRMED' CONFIRMED / CANCELLED / TENTATIVE
notes String(500) nullable
createdAt Instant auto
version Long @Version
  1. ResourceService: CRUD, search by type, availability check for time range, maintenance scheduling
  2. Controller: - POST /api/resources — Create resource - GET /api/resources — List resources (filterable by type, status) - GET /api/resources/{id} — Resource detail with current bookings - PUT /api/resources/{id} — Update resource - GET /api/resources/{id}/availability?from={}&to={} — Check availability for date range - DELETE /api/resources/{id} — Retire resource
  3. Flyway: V502__create_resource.sql, V503__create_resource_booking.sql - Indexes: (entityId, typeCd, statusCd), (resourceId, startDateTime, endDateTime)
  4. Tests: ResourceServiceTest

Result: Resource management with availability tracking.


Step 5.4 — Booking Calendar

Approach:

  1. BookingService: - createBooking(resourceId, start, end, recurrenceRule):
    • Conflict detection: reject if overlapping booking exists for same resource (WHERE resourceId = ? AND statusCd = 'CONFIRMED' AND startDateTime < ? AND endDateTime > ?)
    • Recurring bookings: parse iCalendar RRULE, generate individual occurrences, check conflicts for all
    • cancelBooking(bookingId): set CANCELLED, notify affected parties
    • rescheduleBooking(bookingId, newStart, newEnd): cancel old + create new (with conflict check)
    • getBookings(resourceId, dateRange): calendar data including recurring expansions
  2. Frontend calendar: week/day/month views, drag-and-drop (reschedule), color-coded by type (course=blue, event=green, maintenance=gray), recurring events shown with repeat icon
  3. Tests: conflict detection (overlapping, adjacent, recurring conflicts)

Result: Interactive booking calendar with conflict prevention.


Step 5.5 — Course Management

Create the membership-event module (course part).

(reference: Chapter 11 — Data Model)

Approach:

  1. Create JPA entity: Course
Field Type Constraints Description
id Long PK
entityId Long FK NOT NULL
name String(200) NOT NULL e.g., "Yoga Basics", "CrossFit 7AM"
description String(2000)
instructorUserId Long FK nullable Assigned trainer
resourceId Long FK nullable Assigned room/area
recurrenceRule String(500) NOT NULL iCalendar RRULE (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR")
startTime LocalTime NOT NULL Start time (e.g., 07:00)
durationMinutes Integer NOT NULL Session length
maxParticipants Integer nullable Capacity (null = unlimited)
waitListEnabled boolean default false
levelCd String(50) nullable BEGINNER, INTERMEDIATE, ADVANCED, ALL_LEVELS
categoryCd String(50) nullable FITNESS, YOGA, MARTIAL_ARTS, DANCE, SWIMMING, OTHER
statusCd String(20) default 'ACTIVE' ACTIVE / CANCELLED / PAUSED
imageUrl String(500) nullable
createdAt Instant auto
version Long @Version
  • CourseRegistration: id, courseId, memberId, status (REGISTERED/WAIT_LIST/CANCELLED), registeredAt
  • CourseAttendance: id, courseId, memberId, sessionDate, attended (boolean), markedByUserId
  1. CourseService: - CRUD with resource auto-booking (creates ResourceBooking for each session) - register(courseId, memberId): check capacity, add to wait list if full - cancelRegistration(courseId, memberId): auto-promote first from wait list - markAttendance(courseId, sessionDate, attendanceList): trainer marks who showed up - getSchedule(entityId, weekDate): weekly course schedule (all courses expanded from RRULE) - getParticipants(courseId, sessionDate): list registered members for specific session
  2. Controller: - POST /api/courses — Create course (CLUB_ADMIN/TRAINER) - GET /api/courses — List courses (schedule view) - GET /api/courses/{id} — Course detail with next sessions - PUT /api/courses/{id} — Update course - POST /api/courses/{id}/register — Register member - DELETE /api/courses/{id}/register/{memberId} — Cancel registration - POST /api/courses/{id}/attendance — Mark attendance for session (trainer) - GET /api/courses/schedule/{entityId} — Public weekly schedule (no auth)
  3. Flyway: V504__create_course.sql - Indexes: (entityId, statusCd), (instructorUserId), (resourceId)
  4. Course view in member app: weekly schedule (time slots), course detail with "Register" button, my registered courses
  5. Tests: CourseServiceTest (registration, wait list promotion, attendance, RRULE expansion, capacity)

Result: Course management with scheduling, registration, wait list, and attendance tracking.


Step 5.6 — Utilization Analytics

Approach:

  1. Resource Utilization: - Occupancy rate per resource (booked hours / available hours, per day/week/month) - Peak times heatmap (7x24 grid: day of week × hour, color = utilization %) - Equipment usage: sessions count, hours used, idle periods - Personnel workload: trainer hours per week, courses per trainer, attendance per trainer

  2. Check-in Analytics: - Visits per period (daily/weekly/monthly chart) - Average visit duration (from check-in to check-out) - Peak hours (hourly distribution chart, identifies morning/evening rush) - Retention trends: check-in frequency per member (declining = churn risk) - Zone popularity: which zones get most traffic

  3. Course Analytics: - Fill rate per course (registered / maxParticipants, identify under/over-subscribed) - No-show rate (registered but didn't attend, by course and member) - Popular courses ranking (by registrations, attendance, wait list length) - Instructor performance: average fill rate, attendance rate per trainer

  4. API Endpoints: - GET /api/analytics/resources?from={}&to={} — Resource utilization report - GET /api/analytics/checkins?from={}&to={} — Check-in analytics - GET /api/analytics/courses?from={}&to={} — Course analytics - GET /api/analytics/heatmap?resourceId={}&period={} — Utilization heatmap data

  5. Admin UI: analytics dashboard tab with charts (line, bar, heatmap), date range picker, export to CSV/PDF

Result: Comprehensive resource, check-in, and course analytics with visual dashboards.


Step 5.7 — Phase 5 Documentation

Update doc/enduser/admin-manual.md, doc/enduser/consumer-manual.md, doc/developer/backend-guide.md, doc/developer/api-reference.md.


Step 5.8 — Update Intranet

Run python doc/intranet/build.py.


Phase 5 — Quality Gate

# Check Target
1 Conception exists
2 Compilation + tests 0 errors, 0 failures
3 Coverage >= 65%
4 QR check-in access granted/denied correctly
5 HAL adapters Gantner, OSDP, BLE, QR door, GenericHTTP all instantiate
6 Resource booking no double-booking
7 Course register -> attend -> wait list works
8 Documentation + intranet updated
9 CLAUDE.md updated

Report: "Phase 5 completed."

---