Busflow Docs

Internal documentation portal

Skip to content

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 ​

FieldValue
AuthMANAGER or DISPATCHER role (or CREW_MGMT capability)
MechanismHasura 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 Presettenant_id: x-hasura-tenant-id, status: VALID
Post-insertHasura 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 ​

FieldValue
AuthMANAGER or DISPATCHER role (or CREW_MGMT capability)
MechanismHasura update_crew_qualifications_by_pk mutation
Editable fieldsissued_date, expiry_date, issuing_authority, restriction_notes, restriction_type
Non-editablequalification_type, crew_member_id (delete and recreate to change these)
Post-updateEvent 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 ​

FieldValue
AuthMANAGER role only
MechanismHasura delete_crew_qualifications_by_pk mutation
GuardSoft-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 ​

FieldValue
AuthMANAGER role only
MechanismHasura update_crew_qualifications_by_pk mutation β€” sets status = REVOKED
Post-updateEvent 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:

typescript
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 CaseResolutionEnforcement
E1Qualification revoked while crew member is on active assignmentstatus = 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.
E2Expiry threshold crossed during multi-day tourThe 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.
E3Duplicate qualification types per crew memberAllowed. 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.
E4Crew member has zero qualification recordsTreated 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).
E5Concurrent qualification update + dispatch assignmentThe 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.
E6Scheduled trigger marks EXPIRING_SOON but manager renews same dayManager 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 ​

TableSchema DocDetail
crew_qualificationsschema-backoffice.mdTable definition, qualification catalog, restriction_type enum
crew_membersschema-backoffice.mdrole (DRIVER/GUIDE/DRIVER_GUIDE), status (ACTIVE filter)
vehiclesschema-backoffice.mdtransmission_type for restriction matching
leg_assignmentsschema-operations.mdFuture assignment scanning on revoke/expire
service_legsschema-operations.mdscheduled_end for multi-day expiry check (E2)
change_eventsschema-backoffice.mdAudit trail for all CRUD operations

Internal documentation β€” Busflow