Story QR-006: Employee Clock-In/Out + QR Scanner + Offline Sync¶
@assignee: @m-kouassi
Module: qr-attendance Slice: QR-T2-001 + QR-T5-005 + QR-T5-007 Brand context: [BOTH] Papillon HR Suite + ALTARYS ENTERPRISE HR Suite Priority: 6 Depends on: QR-002 (sites + QR validation), QR-003 (schedules for worked hours), QR-008 (employee site assignments — for non-primary scan flagging) Can develop concurrently with: QR-008 Merge order: Before QR-007, QR-009, QR-011, QR-014, QR-016, QR-018 Estimated complexity: L PRD User Stories: US-QR-001
Objective¶
Deliver the core clock-in/out experience via QR code scanning. This is Papillon's "hero moment" — a mobile-first, offline-first scanning flow that records attendance events, computes daily worked hours, and syncs offline events in batches when connectivity is restored. The story covers the full vertical slice: QR scanner UI, clock event recording, daily attendance aggregation, batch sync with idempotency, and QR validation cache for offline use.
Backend Scope¶
Flyway Migrations¶
V001_012__attendance_clock_event.sql:
CREATE TABLE attendance_clock_event (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
employee_id UUID NOT NULL,
site_id UUID NOT NULL,
event_type VARCHAR(10) NOT NULL CHECK (event_type IN ('IN', 'OUT', 'BREAK_OUT', 'BREAK_IN')),
timestamp TIMESTAMPTZ NOT NULL,
sync_timestamp TIMESTAMPTZ,
device_id_hash VARCHAR(64) NOT NULL,
source VARCHAR(20) NOT NULL DEFAULT 'EMPLOYEE_SCAN' CHECK (source IN ('EMPLOYEE_SCAN', 'SYSTEM', 'HR_REGULARIZATION')),
is_duplicate BOOLEAN NOT NULL DEFAULT false,
auto_close_reason VARCHAR(30),
created_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_clock_event_employee ON attendance_clock_event(tenant_id, employee_id, timestamp);
CREATE INDEX idx_clock_event_site ON attendance_clock_event(tenant_id, site_id, timestamp);
CREATE INDEX idx_clock_event_device ON attendance_clock_event(tenant_id, device_id_hash, timestamp);
V001_013__attendance_daily_attendance.sql:
CREATE TABLE attendance_daily_attendance (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
employee_id UUID NOT NULL,
attendance_date DATE NOT NULL,
status VARCHAR(30) NOT NULL DEFAULT 'EXPECTED',
first_clock_in TIMESTAMPTZ,
last_clock_out TIMESTAMPTZ,
worked_minutes INTEGER DEFAULT 0,
scheduled_minutes INTEGER DEFAULT 0,
overtime_minutes INTEGER DEFAULT 0,
late_minutes INTEGER DEFAULT 0,
early_departure_minutes INTEGER DEFAULT 0,
is_auto_closed BOOLEAN NOT NULL DEFAULT false,
has_anomaly BOOLEAN NOT NULL DEFAULT false,
anomaly_details JSONB,
regularization_id UUID,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(tenant_id, employee_id, attendance_date)
);
CREATE INDEX idx_daily_attendance_date ON attendance_daily_attendance(tenant_id, attendance_date, status);
CREATE INDEX idx_daily_attendance_employee ON attendance_daily_attendance(tenant_id, employee_id, attendance_date);
Entities & Records¶
Clock (tenant DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID | PK, client-generated (for offline idempotency) |
| tenantId | UUID | NOT NULL |
| employeeId | UUID | NOT NULL |
| siteId | UUID | NOT NULL |
| eventType | String | NOT NULL, enum: IN, OUT, BREAK_OUT, BREAK_IN |
| timestamp | OffsetDateTime | NOT NULL |
| syncTimestamp | OffsetDateTime | Nullable (server-receive time) |
| deviceIdHash | String | NOT NULL, 64 chars (SHA-256) |
| source | String | NOT NULL, enum: EMPLOYEE_SCAN, SYSTEM, HR_REGULARIZATION |
| isDuplicate | boolean | NOT NULL, default false |
| autoCloseReason | String | Nullable |
| createdAt | OffsetDateTime | NOT NULL |
DailyAttendance (tenant DB):
| Field | Type | Constraints |
|---|---|---|
| id | UUID | PK |
| tenantId | UUID | NOT NULL |
| employeeId | UUID | NOT NULL |
| attendanceDate | LocalDate | NOT NULL, UNIQUE with tenantId + employeeId |
| status | String | NOT NULL, enum (see below) |
| firstClockIn | OffsetDateTime | Nullable |
| lastClockOut | OffsetDateTime | Nullable |
| workedMinutes | int | default 0 |
| scheduledMinutes | int | default 0 |
| overtimeMinutes | int | default 0 |
| lateMinutes | int | default 0 |
| earlyDepartureMinutes | int | default 0 |
| isAutoClosed | boolean | NOT NULL, default false |
| hasAnomaly | boolean | NOT NULL, default false |
| anomalyDetails | String (JSONB) | Nullable |
| regularizationId | UUID | Nullable |
| createdAt | OffsetDateTime | NOT NULL |
| updatedAt | OffsetDateTime | NOT NULL |
DailyAttendance status enum values: EXPECTED, PRESENT, RETARD, ABSENT, TELETRAVAIL, EN_CONGE, FERIE, INCOMPLET, AUTO_CLOSED, ABSENT_PENDING, DEPART_ANTICIPE, REGULARIZED
DTOs:
public record RecordClockEventRequest(
@NotNull UUID id,
@NotNull UUID siteId,
@NotBlank String qrToken,
@NotNull OffsetDateTime timestamp,
@NotBlank @Size(min = 64, max = 64) String deviceIdHash
) {}
public record ClockEventResponse(
UUID id,
String eventType,
OffsetDateTime timestamp,
String siteName,
String status
) {}
public record BatchSyncRequest(
@NotEmpty List<RecordClockEventRequest> events
) {}
public record BatchSyncResponse(
int synced,
int duplicatesSkipped,
int failed,
List<SyncResult> results
) {}
public record SyncResult(
UUID id,
String status,
String eventType,
String errorMessage
) {}
// SyncResult.status enum: SYNCED, DUPLICATE, FAILED, REJECTED
public record QrCacheResponse(
UUID tenantId,
List<QrCacheSite> sites,
OffsetDateTime generatedAt
) {}
public record QrCacheSite(
UUID siteId,
String token,
String name
) {}
Repository Layer¶
ClockEventRepository.java:findByTenantIdAndEmployeeIdAndTimestampBetween(UUID tenantId, UUID employeeId, OffsetDateTime from, OffsetDateTime to) -> List<Clock>findByTenantIdAndEmployeeIdAndAttendanceDate(UUID tenantId, UUID employeeId, LocalDate date) -> List<Clock>— Custom query filtering by DATE(timestamp)existsById(UUID id) -> boolean— For idempotency checkDailyAttendanceRepository.java:findByTenantIdAndEmployeeIdAndAttendanceDate(UUID tenantId, UUID employeeId, LocalDate date) -> Optional<DailyAttendance>findByTenantIdAndAttendanceDateAndStatus(UUID tenantId, LocalDate date, String status) -> List<DailyAttendance>
Service Layer¶
ClockEventService.java:
Access control layering (read this before the steps below):
This service relies on a layered defense-in-depth model. Each layer has a distinct owner:
| Concern | Owner | Where enforced | Assumed by recordClockEvent |
|---|---|---|---|
| Tenant exists & is operable (not SUSPENDED for non-payment) | Control Plane | TenantResolutionFilter (CP-001) |
✅ Yes — tenantId parameter is trusted to belong to an operable tenant |
| Tenant has subscribed to QRCONTR module + tenant writable | Control Plane | ModuleGatingInterceptor (CP-007 / CP-007bis) + suspended-tenant write denial (CP-020) |
✅ Yes — controller path /api/v1/qrcontr/* already gated |
| Tenant ID matches every query in this service | This service + RLS (ADR-18) | Every repository call includes tenantId; PostgreSQL RLS as defense-in-depth |
❌ Must be enforced explicitly in every query |
| Employee exists & is in the correct domain status (ACTIVE) for clock-in | This service (per-module domain guard, see java-spring-guidelines.md §16) |
requireActiveEmployee(tenantId, employeeId) |
❌ Must be enforced explicitly — service may be called from non-HTTP paths (batch sync, future event consumers) |
| QR token validity (HMAC, site active, token current) | This service | QrCodeValidator.validate() |
❌ Domain rule — always enforced |
Why service-layer checks are still required even when CP gates are in place: the service may be invoked from contexts where the CP interceptor has not run — the batch sync entry point processes a list of events from one HTTP call, scheduled jobs may replay queued events, and future cross-module event consumers may call the service directly. CP-owned checks (tenant operable, module subscribed) need not be re-checked because they apply per-request, not per-event. Domain-owned checks (employee active, QR valid, tenant ID match) MUST be re-checked because they vary per event.
recordClockEvent(UUID tenantId, UUID employeeId, RecordClockEventRequest) -> ClockEventResponse:- Check idempotency: if event with same client-supplied UUID exists, return existing event — HTTP 200 OK, no second insert. (Idempotency is mandatory because offline-queued events retry with the same UUID; see the Idempotency contract section below.)
- Domain guard —
requireActiveEmployee(tenantId, employeeId): load fromAttendanceCachedEmployeeData; throwEmployeeNotFoundExceptionif absent, throwEmployeeNotActiveExceptionif status ≠ ACTIVE. Error code:QRCONTR_010_EMPLOYEE_NOT_ACTIVE. French message: "Compte desactive -- contactez les RH". (Seejava-spring-guidelines.md§16 — Per-Module Domain Guards.) - Validate QR: call
QrCodeValidator.validate()— check HMAC, tenant match, site active, token current. - Determine event type: call
EventTypeDeterminer.determine()— IN if no open session, OUT if session open. - Insert
ClockwithsyncTimestamp = OffsetDateTime.now(). - Call
DailyAttendanceService.upsert()to update daily aggregation. - Publish audit event.
Idempotency contract (why id is client-generated):
The frontend MUST generate a UUIDv7 client-side at the moment of scan and persist it in the IndexedDB queue entry. The same id is sent on every retry until the server acknowledges. This is non-negotiable for offline correctness:
- Offline queue + exponential backoff (max 3 retries) means at-least-once delivery is guaranteed; without a stable
idthe server cannot distinguish "same scan retried" from "two scans". - Single-event endpoint (
POST /clock-events) and batch-sync endpoint (POST /clock-events/sync) share this contract — both retry on flaky 3G. - Server-generated IDs would defeat retry idempotency; therefore
idis@NotNullonRecordClockEventRequest. - Idempotent re-POST returns HTTP 200 (existing event), not 201. First write returns HTTP 201.
EventTypeDeterminer.java:
- determine(UUID tenantId, UUID employeeId, LocalDate date) -> String:
- Query today's clock events for employee
- If no events or last event is OUT/BREAK_IN -> return "IN"
- If last event is IN/BREAK_OUT -> return "OUT"
- Supports multiple IN/OUT pairs per day
DailyAttendanceService.java:
- upsert(UUID tenantId, UUID employeeId, LocalDate date) -> DailyAttendance:
- Find or create DailyAttendance record for the date
- On clock-in: set firstClockIn (if not already set), set status = PRESENT
- On clock-out: set lastClockOut, compute workedMinutes
- Worked hours calculation (FR-QR-042): breakTracking disabled -> sum of all (OUT - IN) pairs minus scheduled break duration
- Worked hours calculation (FR-QR-043): breakTracking enabled (4 scans/day) -> (BREAK_OUT - first IN) + (last OUT - BREAK_IN)
- Compute scheduledMinutes from employee's effective schedule (via ScheduleAssignmentService)
BatchSyncService.java:
- processSync(UUID tenantId, UUID employeeId, BatchSyncRequest) -> BatchSyncResponse:
- Process events one by one in timestamp order (FIFO)
- Per-event: validate, record, track result
- Idempotency: INSERT with UUID check — if exists, mark as DUPLICATE in response
- Return aggregate counts: synced, duplicatesSkipped, failed
API Endpoints¶
| Method | Path | Request Body | Response | Auth |
|---|---|---|---|---|
| POST | /api/v1/qrcontr/clock-events |
RecordClockEventRequest | ApiResponse |
Employee (P4) |
| POST | /api/v1/qrcontr/clock-events/sync |
BatchSyncRequest | ApiResponse |
Employee (P4) |
| GET | /api/v1/qrcontr/clock-events/me |
?date=&dateFrom=&dateTo= | ApiResponse |
Employee (P4) |
| GET | /api/v1/qrcontr/qr-cache |
— | ApiResponse |
Employee (P4) |
Validation Rules¶
idmust be a valid UUID (client-generated for offline idempotency)siteIdmust exist, be active, and belong to tenantqrTokenmust match site's current token (HMAC validation via QrCodeValidator)timestampmust be a valid ISO 8601 with timezonedeviceIdHashmust be exactly 64 characters (SHA-256 hash)- Employee must be ACTIVE (not TERMINATED). Error: "Compte desactive -- contactez les RH"
- Duplicate detection: same employee, same event type, within 15 minutes -> mark
is_duplicate=true, keep earliest - Clock-in before scheduled start: allowed, worked hours count from actual scan time (FR-QR-014)
Multi-Tenant Considerations¶
- All tables include
tenant_idcolumn - All queries MUST filter by
tenant_id - QR validation includes tenant ID check (QR from tenant A cannot be used in tenant B)
- QR cache returns only sites for the authenticated employee's tenant
- Clock events are immutable audit records — never modified or deleted
Frontend Scope¶
Pages & Routes¶
/qrcontr/scan— QR Scanner (full-screen camera)/qrcontr/— Employee home tab "Pointage" (default landing)
Components¶
Components follow design spec wireframes 3.1, 3.2:
QRScanner.tsx (design component spec 8.2):
- Props: onScan, onError, isOffline, torchEnabled
- States: initializing, ready, scanning, success, error
- Full-screen camera viewfinder with QR frame overlay
- Animated amber corners pulsing (1.5s cycle)
ClockConfirmation.tsx: - Full-screen success state: animated checkmark, time display, IN/OUT label, site name, sync status - 2-second auto-dismiss to history - Haptic success pattern (navigator.vibrate)
EmployeeHomePage.tsx: - Greeting with employee name - Today's clock events list - "Scanner le QR" CTA (large, 48px height, amber) - Worked hours counter for current day
OfflineBanner.tsx: - Thin amber banner at top: "Hors ligne -- vos pointages seront synchronises"
SyncIndicator.tsx: - Persistent in top bar area - Green dot = online, amber pulsing dot = syncing, grey dot = offline
UI States (ALL REQUIRED)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading (scanner) | Camera initializing, shimmer on frame | "Demarrage de la camera..." |
| Empty (no camera) | Full-screen permission request | "Autorisez l'acces a la camera pour scanner le QR code." |
| Error (invalid QR) | Red shake animation, error message slides up | "QR code invalide. Contactez les RH." |
| Offline | Scanner works normally, success shows sync pending | "En attente de synchronisation" |
| Success | Full-screen warm gradient, animated checkmark, time | "Pointage enregistre" + "HH:MM" + "Arrivee -- [Site name]" |
Additional states: - Duplicate: amber info state -> "Pointage deja enregistre a HH:MM" (auto-dismiss 2s) - Terminated: red error -> "Compte desactive -- contactez les RH" - Offline QR not in cache: "QR code non reconnu hors ligne. Reessayez en ligne."
Responsive Behavior¶
- 360px (mobile): Full-screen scanner (primary use case). Home page: stacked layout, large scan CTA
- 768px (tablet): Full-screen scanner. Home: 2-column (today's events + hours counter)
- 1024px+ (desktop): Scanner as centered modal (60% width). Home: full dashboard layout
Interactions¶
Scanner flow: - Frame corners lock to QR boundary on detection (haptic feedback) - Success: warm gradient fade-in (300ms) + checkmark scale bounce (0 -> 1.2 -> 1.0, 300ms) + time fade-in - Error: shake (3x, 200ms, +/-10px translateX)
Home page: - "Scanner le QR" CTA -> opens full-screen scanner - Today's events shown as timeline with IN/OUT labels
Offline behavior: - Events stored in IndexedDB queue - Auto-sync within 30 seconds on reconnect (FIFO order) - Exponential backoff on failure (max 3 retries) - Failed sync after retries: "Pointage du {datetime} n'a pas pu etre synchronise. Contactez les RH."
QR cache:
- Refreshed on every successful sync
- Offline validation: compare scanned {siteId, token} against cached pairs
- Cache format: {tenantId, sites: [{siteId, token, name}], generatedAt}
Acceptance Criteria¶
AC-001: Clock-in event recording via QR scan
Given I am logged into the Papillon app and at a site with a posted QR code
When I scan the QR code
Then a clock-in event is recorded with my employee ID, the site ID, and the device timestamp
AC-002: Successful scan confirmation
Given I scan the QR code
When the scan is successful
Then I see a confirmation message: "Pointage enregistre -- [HH:MM]"
AC-003: Offline clock event with queue and sync
Given I am offline
When I scan the QR code
Then the clock event is stored locally and queued for sync
And when connectivity is restored, the event syncs within 30 seconds
AC-004: Idempotent re-POST of the same client-generated UUID
Given I have already submitted a clock event with id X (HTTP 201)
When I POST the same RecordClockEventRequest with id X again (e.g. retry after network blip)
Then the server returns HTTP 200 with the existing event
And no duplicate row is created in attendance_clock_event
And the response includes the original eventType and timestamp
AC-005: Service-layer rejection of non-active employee
Given my AttendanceCachedEmployeeData entry has status = TERMINATED (or the entry is missing)
When the controller calls ClockEventService.recordClockEvent
Then the service throws EmployeeNotActiveException (or EmployeeNotFoundException)
And the API returns HTTP 403 with error code QRCONTR_010_EMPLOYEE_NOT_ACTIVE
And the French message reads "Compte desactive -- contactez les RH"
And no clock event is recorded
OHADA & Regulatory Rules¶
- QR code scanning (non-biometric) is explicitly endorsed by ARTCI as proportionate (vs biometrics which are prohibited).
- Device UUID hash: captured for audit but raw device UUID never stored (FR-QR-091).
- Immutable audit records: All clock events are immutable — never modified or deleted (ADR-10).
- Device clock authoritative: timestamp is from the employee's device. syncTimestamp is server-receive time for audit trail only.
Standards & Conventions¶
docs/standards/java-spring-guidelines.md— Entity records, service layer, immutable eventsdocs/standards/api-guidelines.md— ApiResponse envelope, cursor-based paginationdocs/standards/database-guidelines.md— Multi-tenant table design, TIMESTAMPTZ for timestampsdocs/standards/react-typescript-guidelines.md— Component structure, hooks, animation patternsdocs/standards/offline-sync-guidelines.md— IndexedDB queue, FIFO sync, exponential backoff, idempotency via client UUIDs
Testing Requirements¶
Unit Tests¶
- EventTypeDeterminer: no open IN -> returns IN; open IN -> returns OUT; multiple pairs per day
- DailyAttendanceService: worked minutes calculation without break deduction (breakTracking disabled)
- DailyAttendanceService: worked minutes calculation with break tracking (4 scans)
- QR validation: valid HMAC accepted, invalid HMAC rejected, expired token rejected
- Batch sync: idempotency (same UUID = no duplicate created), per-event validation
- ClockEventService: rejects terminated employee (
EmployeeNotActiveException, error codeQRCONTR_010_EMPLOYEE_NOT_ACTIVE) - ClockEventService: rejects missing employee in
AttendanceCachedEmployeeData(EmployeeNotFoundException) - ClockEventService: idempotent re-POST of same UUID returns existing event, no duplicate row
requireActiveEmployeeguard: ACTIVE → returns; TERMINATED → throwsEmployeeNotActiveException; absent → throwsEmployeeNotFoundException
Integration Tests¶
- POST /clock-events -> 201, clock event created, DailyAttendance upserted
- POST /clock-events with invalid QR -> 400
- POST /clock-events with same UUID twice -> idempotent (no duplicate)
- POST /clock-events/sync -> batch processing with mixed results (synced + duplicates + failed)
- GET /clock-events/me -> returns employee's clock events for date range
- GET /qr-cache -> returns valid site/token pairs for tenant
- Tenant isolation: tenant A's clock events not visible to tenant B
- POST /clock-events at a non-primary site -> DailyAttendance row has
hasAnomaly=truewith NON_PRIMARY_SITE_SCAN entry inanomalyDetails
What QA Will Validate¶
- QR scanner opens full-screen camera on mobile
- Scanning valid QR shows success animation with time
- Scanning invalid QR shows error with shake animation
- Offline scanning stores event and shows sync pending
- Online reconnection triggers batch sync within 30 seconds
- Employee home page shows today's events and worked hours
- All breakpoints (360px, 768px, 1024px) render correctly
- Haptic feedback on scan detection
- Auto-dismiss after 2 seconds on success
Out of Scope¶
- Duplicate detection within 15 minutes (QR-007)
- Terminated employee rejection (QR-007)
- Early departure flagging (QR-007)
- Late arrival detection (QR-009)
- Auto-close batch (QR-010)
- Shared device anomaly detection (QR-010)
- Break scan handling: BREAK_OUT/BREAK_IN events are recorded but synthetic break is not auto-inserted (QR-009)
Definition of Done¶
- [ ] All backend endpoints implemented and returning correct responses
- [ ] All frontend pages render correctly at 360px, 768px, 1024px
- [ ] All 5 UI states implemented (loading, empty, error, offline, success)
- [ ] French micro-copy matches design spec exactly
- [ ] All acceptance criteria pass manually
- [ ] Unit tests written and passing
- [ ] Integration tests written and passing
- [ ] Multi-tenant isolation verified
- [ ] Offline write+sync works (IndexedDB queue, batch sync, QR cache)
- [ ] No TypeScript
any, no Java raw types - [ ] API responses follow standard envelope format
- [ ] Audit trail entries for every clock event