Aller au contenu

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 check
  • DailyAttendanceRepository.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 from AttendanceCachedEmployeeData; throw EmployeeNotFoundException if absent, throw EmployeeNotActiveException if status ≠ ACTIVE. Error code: QRCONTR_010_EMPLOYEE_NOT_ACTIVE. French message: "Compte desactive -- contactez les RH". (See java-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 Clock with syncTimestamp = 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 id the 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 id is @NotNull on RecordClockEventRequest.
  • 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

  • id must be a valid UUID (client-generated for offline idempotency)
  • siteId must exist, be active, and belong to tenant
  • qrToken must match site's current token (HMAC validation via QrCodeValidator)
  • timestamp must be a valid ISO 8601 with timezone
  • deviceIdHash must 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_id column
  • 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 events
  • docs/standards/api-guidelines.md — ApiResponse envelope, cursor-based pagination
  • docs/standards/database-guidelines.md — Multi-tenant table design, TIMESTAMPTZ for timestamps
  • docs/standards/react-typescript-guidelines.md — Component structure, hooks, animation patterns
  • docs/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 code QRCONTR_010_EMPLOYEE_NOT_ACTIVE)
  • ClockEventService: rejects missing employee in AttendanceCachedEmployeeData (EmployeeNotFoundException)
  • ClockEventService: idempotent re-POST of same UUID returns existing event, no duplicate row
  • requireActiveEmployee guard: ACTIVE → returns; TERMINATED → throws EmployeeNotActiveException; absent → throws EmployeeNotFoundException

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=true with NON_PRIMARY_SITE_SCAN entry in anomalyDetails

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