CrewQualification CRUD & Dispatch Validation Protocol β
This document specifies the CrewQualification entity lifecycle β CRUD operations via Hasura mutations and the dispatch-time validation contract consumed by the AssignCrew Hasura Action. It also covers edge state handling for qualification changes affecting active assignments.
Context: Backoffice (data ownership) Β· Operations (dispatch validation consumer) Β· Communications (expiry notifications) DDD pattern: Simple CRUD via Hasura (DDD Β§3), cross-context reads via live SQL views (DDD Β§7.2) Schema: schema-backoffice.md Β§crew_qualifications
CRUD Operations β
Qualification CRUD follows the DDD Β§3 pragmatic CQRS pattern: simple CRUD via Hasura mutations, not routed through NestJS Actions.
Create Qualification β
| Field | Value |
|---|---|
| Auth | MANAGER or DISPATCHER role (or CREW_MGMT capability) |
| Mechanism | Hasura insert_crew_qualifications_one mutation |
| Input | { crew_member_id, qualification_type, issued_date?, expiry_date?, issuing_authority?, restriction_notes?, restriction_type? } |
| Validation (Pre-insert webhook) | (1) crew_member_id references an ACTIVE crew member in the same tenant. (2) qualification_type is in the allowed enum. (3) If the type requires expiry_date (see qualification catalog), reject if null. (4) expiry_date > issued_date if both provided. |
| Column Preset | tenant_id: x-hasura-tenant-id, status: VALID |
| Post-insert | Hasura Event Trigger β NestJS checks if expiry_date is within the EXPIRING_SOON threshold (default: 30 days, configurable per tenant via operator settings) β sets status = EXPIRING_SOON if applicable. Logs to change_events. |
Update Qualification β
| Field | Value |
|---|---|
| Auth | MANAGER or DISPATCHER role (or CREW_MGMT capability) |
| Mechanism | Hasura update_crew_qualifications_by_pk mutation |
| Editable fields | issued_date, expiry_date, issuing_authority, restriction_notes, restriction_type |
| Non-editable | qualification_type, crew_member_id (delete and recreate to change these) |
| Post-update | Event Trigger β NestJS re-evaluates status based on new expiry_date. If status changes (e.g., EXPIRED β VALID after renewal): (1) the dispatch availability view auto-updates via live SQL joins β no cross-context domain event required; (2) if the qualification is a dispatch-blocking type (LICENSE_D, MODULE_95, PERSONENBEFOERDERUNGSSCHEIN) and status transitions to EXPIRED or REVOKED, the handler scans operations.leg_assignments for future assignments of this crew member and notifies dispatchers via Communications. DIGITAL_TACHOGRAPH_CARD is dispatch-blocking only when the operator has the TACHOGRAPH integration enabled (see module-gated validation). |
Delete Qualification β
| Field | Value |
|---|---|
| Auth | MANAGER role only |
| Mechanism | Hasura delete_crew_qualifications_by_pk mutation |
| Guard | Soft-delete not implemented β qualification records are hard-deleted. The system captures the pre-delete state in change_events. |
Usage guidance: Delete is for data correction (e.g., removing a duplicate or erroneously created record). To withdraw a valid qualification from a crew member, use Revoke β which includes future-assignment scanning and dispatcher notification.
Revoke Qualification β
| Field | Value |
|---|---|
| Auth | MANAGER role only |
| Mechanism | Hasura update_crew_qualifications_by_pk mutation β sets status = REVOKED |
| Post-update | Event Trigger β NestJS evaluates if the revoked qualification is a dispatch-blocking type. If so: scans leg_assignments for future assignments of this crew member β flags affected assignments β notifies dispatchers. |
Dispatch Validation Contract β
When a dispatcher creates a LegAssignment, the backend validates crew qualifications as part of the AssignCrew Hasura Action (routed through NestJS for complex validation).
function validateCrewQualifications(crew_member_id, vehicle_id, service_leg):
qualifications = crew_qualifications.where(crew_member_id)
vehicle = vehicles.find(vehicle_id)
errors = [] // Hard blocks β prevent assignment
warnings = [] // Soft warnings β allow with confirmation
// 1. Required qualifications (hard block if missing or expired/revoked)
// Note: Phase 1 requires LICENSE_D for all vehicles regardless of class/capacity.
// Phase 2: LICENSE_D1 accepted for vehicles with capacity β€ 16 pax
// (EU Directive 2006/126/EC, Art 4 β D1 covers β€ 16 passengers).
for type in [LICENSE_D, MODULE_95, PERSONENBEFOERDERUNGSSCHEIN]:
qual = qualifications.find(type)
if qual is null:
errors.push({ type, reason: 'MISSING' })
elif qual.status in [EXPIRED, REVOKED]:
errors.push({ type, reason: qual.status })
// 2. Module-gated: DIGITAL_TACHOGRAPH_CARD
// Hard block only when operator has enabled the TACHOGRAPH integration.
// Otherwise: warning only β operators should not be forced to track this
// before they opt into the Digital Tachograph module.
tachograph_enabled = operator_integrations.exists(type: TACHOGRAPH, status: CONNECTED)
qual = qualifications.find(DIGITAL_TACHOGRAPH_CARD)
if tachograph_enabled:
if qual is null:
errors.push({ type: DIGITAL_TACHOGRAPH_CARD, reason: 'MISSING' })
elif qual.status in [EXPIRED, REVOKED]:
errors.push({ type: DIGITAL_TACHOGRAPH_CARD, reason: qual.status })
else:
if qual is not null and qual.status in [EXPIRED, REVOKED]:
warnings.push({ type: DIGITAL_TACHOGRAPH_CARD, reason: qual.status })
// 3. Transmission match (uses structured restriction_type enum)
if vehicle.transmission_type == 'MANUAL':
if any qual.restriction_type == 'AUTOMATIC_ONLY':
errors.push({ type: 'TRANSMISSION', reason: 'AUTOMATIC_ONLY_RESTRICTION' })
// 4. Expiring-soon qualifications
for qual in qualifications.where(status == 'EXPIRING_SOON'):
if qual.expiry_date < service_leg.scheduled_end:
errors.push({ type: qual.qualification_type, reason: 'EXPIRES_DURING_TRIP' })
else:
warnings.push({ type: qual.qualification_type, reason: 'EXPIRING_SOON' })
// 5. ADR / FIRST_AID (warnings only)
for type in [ADR, FIRST_AID]:
qual = qualifications.find(type)
if qual is not null and qual.status in [EXPIRED, REVOKED]:
warnings.push({ type, reason: qual.status })
// 6. BORDER_VISA (Phase 1: warning only)
// Phase 2: cross-reference route countries with visa restriction_notes
for qual in qualifications.where(type == BORDER_VISA):
if qual.status in [EXPIRED, REVOKED]:
warnings.push({ type: BORDER_VISA, reason: qual.status })
return { valid: errors.isEmpty(), errors, warnings }Return contract:
interface QualificationValidationResult {
valid: boolean;
errors: QualificationIssue[]; // Hard blocks
warnings: QualificationIssue[]; // Overridable
}
interface QualificationIssue {
type: QualificationType;
reason: 'MISSING' | 'EXPIRED' | 'REVOKED' | 'EXPIRING_SOON'
| 'EXPIRES_DURING_TRIP' | 'AUTOMATIC_ONLY_RESTRICTION';
}Edge States β
| # | Edge Case | Resolution | Enforcement |
|---|---|---|---|
| E1 | Qualification revoked while crew member is on active assignment | status = REVOKED triggers Event Trigger β NestJS scans operations.leg_assignments for future (not in-progress) assignments. The system flags affected future assignments via a dispatcher notification. In-progress legs (service_legs.status = ACTIVE) are not interrupted β the driver completes the current trip. | Backend β Event Trigger + NestJS handler queries Operations read model. |
| E2 | Expiry threshold crossed during multi-day tour | The availability engine checks qualification.expiry_date >= service_leg.scheduled_end, not just scheduled_start. If a LICENSE_D expires on day 3 of a 5-day tour, the system blocks the crew member from assignment even though day 1 is valid. Once assigned, mid-trip expiry does not auto-remove the assignment β the scheduled trigger marks the qualification as EXPIRING_SOON and notifies the manager. | Validation β assignment-time check against scheduled_end. Runtime β scheduled trigger + notification. |
| E3 | Duplicate qualification types per crew member | Allowed. A crew member may hold two BORDER_VISA entries (e.g., UK visa + Swiss permit) or two FIRST_AID certs (basic + extended). The dispatch validation uses ANY semantics: if any qualification of the required type is VALID, the check passes. No unique constraint on (crew_member_id, qualification_type). | Schema β no uniqueness constraint. Validation β ANY pass logic. |
| E4 | Crew member has zero qualification records | Treated as unqualified β all required qualification checks return MISSING, resulting in a π΄ BLOCKED status. The system blocks the crew member from dispatch until the user adds at least LICENSE_D, MODULE_95, and PERSONENBEFOERDERUNGSSCHEIN. DIGITAL_TACHOGRAPH_CARD is only required if the operator has enabled the TACHOGRAPH integration β otherwise it produces a π‘ Warning. | Validation β qual is null β errors.push(MISSING). |
| E5 | Concurrent qualification update + dispatch assignment | The assignment validation reads qualification data at transaction time. If the system revokes a qualification between the dispatcher clicking "Assign" and the backend processing, the validation catches the revoked status and rejects. No stale-read risk because Hasura Action handlers use a single database transaction. | Backend β transactional read within Hasura Action handler. |
| E6 | Scheduled trigger marks EXPIRING_SOON but manager renews same day | Manager updates expiry_date. The post-update Event Trigger re-evaluates: new_expiry_date - CURRENT_DATE > threshold β status reverts to VALID. The QualificationExpiring notification may have already been sent β Communications handles this gracefully (idempotent; no "un-expiring" notification). | Backend β Event Trigger re-evaluation on update. Communications β idempotent notification. |
Schema Cross-References β
| Table | Schema Doc | Detail |
|---|---|---|
crew_qualifications | schema-backoffice.md | Table definition, qualification catalog, restriction_type enum |
crew_members | schema-backoffice.md | role (DRIVER/GUIDE/DRIVER_GUIDE), status (ACTIVE filter) |
vehicles | schema-backoffice.md | transmission_type for restriction matching |
leg_assignments | schema-operations.md | Future assignment scanning on revoke/expire |
service_legs | schema-operations.md | scheduled_end for multi-day expiry check (E2) |
change_events | schema-backoffice.md | Audit trail for all CRUD operations |