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:
- 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]}
- MEMBERSHIP_TIER:
-
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 |
-
Create
CheckInService: -processCheckIn(memberId, zoneId, method, credentialId):- Validate member has active contract (not expired, not suspended)
- Evaluate AccessRules for zone (all active rules must pass)
- Check anti-passback (not already checked in to this zone)
- If occupancy-limited: check currentOccupancy < maxOccupancy
- Record CheckIn with GRANTED/DENIED status
- If GRANTED: increment zone occupancy, publish
checkin.grantedevent (RabbitMQ) - If DENIED: record denialReason, publish
checkin.deniedevent -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
-
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) -
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 -
Create Flyway:
V500__create_checkin.sql,V501__create_access_control.sql- Indexes: (entityId, memberId, checkedInAt), (zoneId, directionCd), (credentialId), (memberId, statusCd) on Credential -
Create kiosk-mode UI component (full-screen check-in interface for tablets at entrance)
- 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
- Define
AccessControlAdapterinterface: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); } 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- Tests with WireMock for Gantner API
Sub-Step 5.2b — OSDP v2 Protocol
OsdpAdapter: SIA OSDP v2 protocol implementation - OSDP gateway service (AES-128 encrypted communication) - Door controller abstraction - Replace legacy Wiegand where applicable- Unit tests for OSDP message framing
Sub-Step 5.2c — BLE Mobile Credentials
BleAdapter: Bluetooth Low Energy proximity unlock - Range configuration per zone (1m / 3m / 5m) - Battery and offline considerations - Flutter:flutter_blue_plusintegration for mobile credential broadcast- Tests for range validation logic
Sub-Step 5.2d — QR Door Access
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)- Tests for token validation and anti-replay
Sub-Step 5.2e — Generic Adapters
GenericHttpAdapter: for any door system with HTTP/REST APISaltoAdapter,DormakabaAdapter: vendor stubs (implemented when needed)- 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:
- 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 |
ResourceService: CRUD, search by type, availability check for time range, maintenance scheduling- 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 - Flyway:
V502__create_resource.sql,V503__create_resource_booking.sql- Indexes: (entityId, typeCd, statusCd), (resourceId, startDateTime, endDateTime) - Tests:
ResourceServiceTest
Result: Resource management with availability tracking.
Step 5.4 — Booking Calendar
Approach:
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 partiesrescheduleBooking(bookingId, newStart, newEnd): cancel old + create new (with conflict check)getBookings(resourceId, dateRange): calendar data including recurring expansions
- Conflict detection: reject if overlapping booking exists for same resource (
- 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
- 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:
- 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), registeredAtCourseAttendance: id, courseId, memberId, sessionDate, attended (boolean), markedByUserId
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- 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) - Flyway:
V504__create_course.sql- Indexes: (entityId, statusCd), (instructorUserId), (resourceId) - Course view in member app: weekly schedule (time slots), course detail with "Register" button, my registered courses
- 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:
-
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
-
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
-
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
-
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 -
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."