diff --git a/apps/delivery-api/prisma/migrations/20261002140000_kfood_delivery_navigation_grace/migration.sql b/apps/delivery-api/prisma/migrations/20261002140000_kfood_delivery_navigation_grace/migration.sql new file mode 100644 index 00000000..72895f25 --- /dev/null +++ b/apps/delivery-api/prisma/migrations/20261002140000_kfood_delivery_navigation_grace/migration.sql @@ -0,0 +1,7 @@ +-- Delivery work ends before the existing mobile return-navigation session. +-- No historical route is marked completed by this additive migration. +ALTER TABLE "route_plans" + ADD COLUMN "deliveryWorkCompletedAt" TIMESTAMPTZ(6), + ADD COLUMN "driverNavigationUntil" TIMESTAMPTZ(6), + ADD COLUMN "deliveryWorkCompletedGeneration" BIGINT, + ADD COLUMN "deliveryWorkCompletedVersionId" UUID; diff --git a/apps/delivery-api/prisma/schema.prisma b/apps/delivery-api/prisma/schema.prisma index 48e224ca..569ced2c 100644 --- a/apps/delivery-api/prisma/schema.prisma +++ b/apps/delivery-api/prisma/schema.prisma @@ -1434,6 +1434,10 @@ model RoutePlan { status RoutePlanStatus @default(READY) driverId String? @db.Uuid assignmentGeneration BigInt @default(1) + deliveryWorkCompletedAt DateTime? @db.Timestamptz(6) + driverNavigationUntil DateTime? @db.Timestamptz(6) + deliveryWorkCompletedGeneration BigInt? + deliveryWorkCompletedVersionId String? @db.Uuid driver Driver? @relation(fields: [driverId], references: [id], onDelete: SetNull) vehicleId String? @db.Uuid vehicle Vehicle? @relation(fields: [vehicleId], references: [id], onDelete: SetNull) diff --git a/apps/delivery-api/scripts/test-disposable-db-integrations.sh b/apps/delivery-api/scripts/test-disposable-db-integrations.sh index b1e08f28..ac6e547d 100644 --- a/apps/delivery-api/scripts/test-disposable-db-integrations.sh +++ b/apps/delivery-api/scripts/test-disposable-db-integrations.sh @@ -369,3 +369,8 @@ DATABASE_URL="$completion_assistance_url" \ COMPLETION_ASSISTANCE_DATABASE_URL="$completion_assistance_url" \ COMPLETION_ASSISTANCE_DATABASE_TARGET_CLASS='safe-local-completion-assistance-disposable' \ npm test -- completion-assistance.integration.test.ts + +DATABASE_URL="$completion_assistance_url" \ +KFOOD_COMPLETION_DATABASE_URL="$completion_assistance_url" \ +KFOOD_COMPLETION_DATABASE_TARGET_CLASS='safe-local-kfood-completion-disposable' \ +npm test -- kfood-delivery-completion.integration.test.ts diff --git a/apps/delivery-api/src/modules/driver/completion-assistance.service.ts b/apps/delivery-api/src/modules/driver/completion-assistance.service.ts index af9fe9d1..5c17f7bd 100644 --- a/apps/delivery-api/src/modules/driver/completion-assistance.service.ts +++ b/apps/delivery-api/src/modules/driver/completion-assistance.service.ts @@ -3,9 +3,25 @@ import { isDeepStrictEqual } from 'node:util'; import { Prisma, type PrismaClient, type DriverCompletionRun, type DriverCompletionCandidate, type DeliveryStop } from '@prisma/client'; import { parseCompletionPolicy, type CompletionPolicy, type CompletionRun, type CompletionCandidate, type CompletionCommand, type CompletionSample, type CompletionAcknowledgement } from './completion-assistance.contract.js'; import { validateVisitEvidence } from './completion-assistance.evidence.js'; +import { + KFOOD_DELIVERY_APP_ID, + KFOOD_DELIVERY_SHOP_DOMAIN, + reconcileKfoodDeliveryWorkCompletion +} from '../route-plans/kfood-delivery-completion.js'; const DAY_MS = 86_400_000; const NONTERMINAL = new Set(['PENDING', 'ASSIGNED', 'EN_ROUTE', 'ARRIVED']); +const TERMINAL_STOP_STATUSES = new Set(['CANCELLED', 'DELIVERED', 'FAILED', 'SKIPPED']); +const KFOOD_RETURN_INTENT_POLICY: CompletionPolicy = { + version: 'kfood_return_intent_only_v1', + maxAccuracyMeters: 20, + enterRadiusMeters: 50, + exitRadiusMeters: 100, + dwellMs: 60_000, + maxGapMs: 30_000, + minDwellSamples: 3, + ambiguityRadiusMeters: 200 +}; type Tx = Prisma.TransactionClient; type Env = Partial>; type Identity = Pick; @@ -43,8 +59,20 @@ export class PrismaCompletionAssistanceService { where: { driver: { accountId, status: 'ACTIVE' }, shop: { appId: 'clever', shopDomain: { endsWith: '.myshopify.com' } }, status: { in: ['IN_PROGRESS', 'READY', 'ASSIGNED', 'PUBLISHED'] } }, select: { id: true }, orderBy: { id: 'asc' } }); - for (const route of routes) await this.issueRun(accountId, route.id, settings, now); + for (const route of routes) await this.issueRun(accountId, route.id, settings, now, false); } + // K-food's explicit "return to store" action is independent of GPS + // inference. App v1 needs a durable run identity even while detection and + // its policy are disabled. + const kfoodRoutes = await this.prisma.routePlan.findMany({ + where: { + driver: { accountId, status: 'ACTIVE' }, + shop: { appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN }, + status: 'IN_PROGRESS' + }, + select: { id: true }, orderBy: { id: 'asc' } + }); + for (const route of kfoodRoutes) await this.issueRun(accountId, route.id, settings, now, true); const records = await this.prisma.driverCompletionRun.findMany({ where: { accountId }, orderBy: [{ createdAt: 'asc' }, { id: 'asc' }] }); const runs: CompletionRun[] = []; const candidates: CompletionCandidate[] = []; @@ -60,8 +88,14 @@ export class PrismaCompletionAssistanceService { const lastPolicy = versions.at(-1); const stored = lastPolicy === undefined ? null : await tx.driverCompletionPolicy.findUnique({ where: { version: lastPolicy } }); const validSettings = settings.detectionEnabled && settings.accounts.has(accountId) && run.invalidatedAt === null; - const policy = validSettings && stored !== null && settings.policy?.version === stored.version - && isDeepStrictEqual(stored.policy, settings.policy) ? stored.policy as CompletionPolicy : null; + const commandOnlyKfood = await this.hasKfoodReturnIntentOnlyMarker(tx, run); + const safeCommandProjection = commandOnlyKfood && run.invalidatedAt === null && run.activationId === null + && stored?.version === KFOOD_RETURN_INTENT_POLICY.version + && isDeepStrictEqual(stored.policy, KFOOD_RETURN_INTENT_POLICY) + && (run.stops as CompletionRun['stops']).every((stop) => stop.coordinates === null); + const policy = safeCommandProjection ? KFOOD_RETURN_INTENT_POLICY + : validSettings && stored !== null && settings.policy?.version === stored.version + && isDeepStrictEqual(stored.policy, settings.policy) ? stored.policy as CompletionPolicy : null; return { run: { runId: run.id, routePlanId: run.routePlanId, assignmentGeneration: run.assignmentGeneration.toString(), expectedRouteVersionId: run.expectedRouteVersionId, routeName: run.routeName, policy: policy as CompletionPolicy, stops, @@ -97,9 +131,13 @@ export class PrismaCompletionAssistanceService { const now = this.now(); const run = await this.reconcileRun(tx, storedRun, now); let result: CompletionAcknowledgement; - if (command.kind === 'candidate') result = await this.registerCandidate(tx, run, command, now); + if (command.kind !== 'return_intent' && await this.hasKfoodReturnIntentOnlyMarker(tx, run)) { + result = ack(command, 'rejected', null, 'return_intent_only'); + } else if (command.kind === 'candidate') result = await this.registerCandidate(tx, run, command, now); else if (command.kind === 'response') result = await this.respond(tx, run, command, now); - else result = ack(command, run.invalidatedAt === null ? 'applied' : 'rejected', null, run.invalidatedAt === null ? undefined : 'run_invalidated'); + else { + result = await this.returnIntent(tx, run, command, now); + } await tx.driverCompletionReceipt.create({ data: { accountId, commandId: command.commandId, runId: run.id, candidateId: result.candidate?.candidateId ?? null, @@ -153,26 +191,32 @@ export class PrismaCompletionAssistanceService { return applied; } - private async issueRun(accountId: string, routePlanId: string, settings: ReturnType, now: Date) { + private async issueRun(accountId: string, routePlanId: string, settings: ReturnType, now: Date, allowKfoodCommandRun: boolean) { await this.prisma.$transaction(async (tx) => { await tx.$queryRaw`SELECT id FROM route_plans WHERE id = ${routePlanId}::uuid FOR UPDATE`; const route = await tx.routePlan.findFirst({ where: { id: routePlanId, driver: { accountId, status: 'ACTIVE' } }, include: { + shop: { select: { appId: true, shopDomain: true } }, routeStops: { include: { deliveryStop: true }, orderBy: { sequence: 'asc' } }, routeGroupingChildVersions: { where: { status: 'CURRENT', supersededAt: null }, take: 2 } } }); if (route === null || route.driverId === null || !['IN_PROGRESS', 'READY', 'ASSIGNED', 'PUBLISHED'].includes(route.status)) return; const version = route.routeGroupingChildVersions.length === 1 ? route.routeGroupingChildVersions[0] : undefined; - if (version === undefined || version.driverId !== route.driverId || settings.policy === null) return; - // Global version is immutable: reusing a version for changed thresholds disables detection. - await tx.driverCompletionPolicy.createMany({ data: { version: settings.policy.version, policy: json(settings.policy), createdAt: now }, skipDuplicates: true }); - const existingPolicy = await tx.driverCompletionPolicy.findUniqueOrThrow({ where: { version: settings.policy.version } }); - if (!isDeepStrictEqual(existingPolicy.policy, settings.policy)) return; + const kfoodCommandRun = allowKfoodCommandRun && route.shop.appId === KFOOD_DELIVERY_APP_ID + && route.shop.shopDomain === KFOOD_DELIVERY_SHOP_DOMAIN && route.status === 'IN_PROGRESS'; + const runPolicy = kfoodCommandRun ? KFOOD_RETURN_INTENT_POLICY : settings.policy; + if (version === undefined || version.driverId !== route.driverId || runPolicy === null) return; + // Global version is immutable: reusing a version for changed thresholds disables the run. + await tx.driverCompletionPolicy.createMany({ data: { version: runPolicy.version, policy: json(runPolicy), createdAt: now }, skipDuplicates: true }); + const existingPolicy = await tx.driverCompletionPolicy.findUniqueOrThrow({ where: { version: runPolicy.version } }); + if (!isDeepStrictEqual(existingPolicy.policy, runPolicy)) return; const identity = { routePlanId, assignmentGeneration: route.assignmentGeneration, expectedRouteVersionId: version.id }; const existing = await tx.driverCompletionRun.findUnique({ where: { routePlanId_assignmentGeneration_expectedRouteVersionId: identity } }); if (existing !== null) { if (existing.accountId !== accountId || existing.driverId !== route.driverId) return; const versions = existing.policyVersions as string[]; - if (versions.at(-1) !== settings.policy.version) await tx.driverCompletionRun.update({ where: { id: existing.id }, data: { policyVersions: [...versions, settings.policy.version] } }); + if (versions.at(-1) !== runPolicy.version) { + await tx.driverCompletionRun.update({ where: { id: existing.id }, data: { policyVersions: [...versions, runPolicy.version] } }); + } return; } const ended = await tx.driverEvent.findFirst({ where: { routePlanId, assignmentGeneration: route.assignmentGeneration, eventType: 'ROUTE_COMPLETED' }, select: { id: true } }); @@ -180,12 +224,93 @@ export class PrismaCompletionAssistanceService { await tx.driverCompletionRun.create({ data: { id: randomUUID(), ...identity, accountId, shopId: route.shopId, driverId: route.driverId, routeName: route.name, stops: json(route.routeStops.map(({ deliveryStop: stop }) => ({ deliveryStopId: stop.id, status: stop.status, - coordinates: stop.geocodeStatus === 'RESOLVED' && stop.latitude !== null && stop.longitude !== null ? { latitude: Number(stop.latitude), longitude: Number(stop.longitude) } : null }))), - policyVersions: [settings.policy.version], activationId: settings.workerEnabled && now >= settings.activatedAt ? settings.activationId : null, createdAt: now + coordinates: !kfoodCommandRun && stop.geocodeStatus === 'RESOLVED' && stop.latitude !== null && stop.longitude !== null + ? { latitude: Number(stop.latitude), longitude: Number(stop.longitude) } : null }))), + policyVersions: [runPolicy.version], + activationId: !kfoodCommandRun && settings.workerEnabled && now >= settings.activatedAt ? settings.activationId : null, createdAt: now } }); }); } + private async returnIntent(tx: Tx, run: DriverCompletionRun, command: Extract, now: Date) { + if (run.invalidatedAt !== null) return ack(command, 'rejected', null, 'run_invalidated'); + const shop = await tx.shop.findUnique({ where: { id: run.shopId }, select: { appId: true, shopDomain: true } }); + if (shop?.appId !== KFOOD_DELIVERY_APP_ID || shop.shopDomain !== KFOOD_DELIVERY_SHOP_DOMAIN) { + return ack(command, 'applied', null); + } + const route = await tx.routePlan.findFirst({ + where: { + id: run.routePlanId, shopId: run.shopId, driverId: run.driverId, assignmentGeneration: run.assignmentGeneration, + status: 'IN_PROGRESS', shop: { appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN } + }, + select: { + routeGroupingChildVersions: { + where: { status: 'CURRENT', supersededAt: null }, take: 2, + select: { id: true, snapshot: true } + }, + routeStops: { + orderBy: { sequence: 'asc' }, + select: { + sequence: true, deliveryStopId: true, + deliveryStop: { select: { orderId: true, status: true, order: { select: { currentRouteVersionId: true } } } } + } + } + } + }); + if (route === null) return ack(command, 'rejected', null, 'return_intent_not_available'); + const version = route.routeGroupingChildVersions.length === 1 ? route.routeGroupingChildVersions[0] : undefined; + if (version === undefined || version.id !== run.expectedRouteVersionId + || !hasExactCurrentMembership(version.snapshot, version.id, route.routeStops)) { + return ack(command, 'rejected', null, 'route_membership_changed'); + } + const unresolved = route.routeStops.filter(({ deliveryStop }) => !TERMINAL_STOP_STATUSES.has(deliveryStop.status)); + if (unresolved.length === 0) { + const completion = await reconcileKfoodDeliveryWorkCompletion(tx, { routePlanId: run.routePlanId, shopId: run.shopId, now }); + return ack(command, completion === null ? 'rejected' : 'applied', null, completion === null ? 'route_completion_not_available' : undefined); + } + const finalStop = route.routeStops.at(-1); + if (unresolved.length !== 1 || finalStop === undefined || unresolved[0]?.deliveryStopId !== finalStop.deliveryStopId + || finalStop.deliveryStop.status !== 'ARRIVED') { + return ack(command, 'rejected', null, 'final_arrival_not_ready'); + } + const updated = await tx.deliveryStop.updateMany({ + where: { id: finalStop.deliveryStopId, shopId: run.shopId, status: 'ARRIVED' }, + data: { status: 'DELIVERED' } + }); + if (updated.count !== 1) return ack(command, 'rejected', null, 'final_arrival_changed'); + const eventId = randomUUID(); + await tx.driverEvent.create({ data: { + id: eventId, shopId: run.shopId, driverId: run.driverId, routePlanId: run.routePlanId, + routeVersionId: run.expectedRouteVersionId, expectedRouteVersionId: run.expectedRouteVersionId, + assignmentGeneration: run.assignmentGeneration, deliveryStopId: finalStop.deliveryStopId, + clientEventId: `completion-assistance:return-intent:${command.commandId}`, driverContractVersion: 2, + eventType: 'STOP_DELIVERED', occurredAt: new Date(command.occurredAt), + payload: { + source: 'DRIVER_RETURN_INTENT', previousStatus: 'ARRIVED', nextStatus: 'DELIVERED', + processedAt: now.toISOString(), returnIntentCommandId: command.commandId + } + } }); + await tx.customerRouteNotificationFact.create({ data: { + shopId: run.shopId, routePlanId: run.routePlanId, deliveryStopId: finalStop.deliveryStopId, + orderId: finalStop.deliveryStop.orderId, + idempotencyKey: `completion-assistance:return-intent:${command.commandId}`, + source: 'COMPLETION_ASSISTANCE', requestedUiStatus: 'COMPLETED', occurredAt: now, + status: 'SKIPPED', errorCode: 'COMPLETION_ASSISTANCE_NOTIFICATIONS_DISABLED', + metadata: { driverEventId: eventId, source: 'DRIVER_RETURN_INTENT', previousStatus: 'ARRIVED', nextStatus: 'DELIVERED' } + } }); + const completion = await reconcileKfoodDeliveryWorkCompletion(tx, { routePlanId: run.routePlanId, shopId: run.shopId, now }); + if (completion === null) throw new Error('K-food delivery completion invariant changed during return intent'); + return ack(command, 'applied', null); + } + + private async hasKfoodReturnIntentOnlyMarker(tx: Tx, run: DriverCompletionRun) { + const versions = run.policyVersions as string[]; + if (versions.at(-1) !== KFOOD_RETURN_INTENT_POLICY.version) return false; + return await tx.shop.findFirst({ where: { + id: run.shopId, appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN + }, select: { id: true } }) !== null; + } + private async reconcileRun(tx: Tx, original: DriverCompletionRun, now: Date): Promise { let run = await tx.driverCompletionRun.findUniqueOrThrow({ where: { id: original.id } }); const route = await tx.routePlan.findUnique({ where: { id: run.routePlanId }, include: { @@ -324,6 +449,7 @@ export class PrismaCompletionAssistanceService { errorCode: 'COMPLETION_ASSISTANCE_NOTIFICATIONS_DISABLED', metadata: { driverEventId: eventId, candidateId: row.id, revision: next.revision, source, previousStatus: stop.status, nextStatus: target } } }); + await reconcileKfoodDeliveryWorkCompletion(tx, { routePlanId: run.routePlanId, shopId: run.shopId, now }); return updated; } @@ -365,6 +491,33 @@ async function currentStops(tx: Tx, run: DriverCompletionRun): Promise [stop.id, stop.status])); return snapshots.map((stop) => ({ ...stop, status: statusById.get(stop.deliveryStopId) ?? 'CANCELLED' })); } + +type ReturnIntentStop = { + sequence: number; + deliveryStopId: string; + deliveryStop: { orderId: string; status: string; order: { currentRouteVersionId: string | null } }; +}; + +function hasExactCurrentMembership(snapshot: unknown, versionId: string, stops: ReturnIntentStop[]): boolean { + if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot) || stops.length === 0) return false; + const record = snapshot as Record; + if (record.membershipSchemaVersion !== undefined && record.membershipSchemaVersion !== 1) return false; + if (!Array.isArray(record.stops) || record.stops.length !== stops.length) return false; + const members = record.stops as unknown[]; + const stopIds = new Set(); + const orderIds = new Set(); + return stops.every((stop, index) => { + const member = members[index]; + if (member === null || typeof member !== 'object' || Array.isArray(member)) return false; + const tuple = member as Record; + if (stopIds.has(stop.deliveryStopId) || orderIds.has(stop.deliveryStop.orderId)) return false; + stopIds.add(stop.deliveryStopId); + orderIds.add(stop.deliveryStop.orderId); + return stop.sequence === index + 1 && tuple.sequence === stop.sequence + && tuple.deliveryStopId === stop.deliveryStopId && tuple.orderId === stop.deliveryStop.orderId + && stop.deliveryStop.order.currentRouteVersionId === versionId; + }); +} async function serverSamples(tx: Tx, run: DriverCompletionRun, candidate: CompletionCandidate, now: Date): Promise { const first = candidate.evidence[0]?.occurredAt ?? candidate.arrivalAt; const events = await tx.driverEvent.findMany({ where: { diff --git a/apps/delivery-api/src/modules/driver/driver-event.repository.ts b/apps/delivery-api/src/modules/driver/driver-event.repository.ts index 906f94dd..28d25a9a 100644 --- a/apps/delivery-api/src/modules/driver/driver-event.repository.ts +++ b/apps/delivery-api/src/modules/driver/driver-event.repository.ts @@ -2,6 +2,7 @@ import { Prisma } from '@prisma/client'; import type { PrismaClient } from '@prisma/client'; import { randomUUID } from 'node:crypto'; import { safeErrorCode } from '../security/safe-telemetry-redaction.js'; +import { hasDeliveryNavigationGraceExpired, hasDeliveryWorkCompleted, KFOOD_DELIVERY_SHOP_DOMAIN, reconcileKfoodDeliveryWorkCompletion, type DeliveryWorkCompletionRecord } from '../route-plans/kfood-delivery-completion.js'; import { assertRouteDispatchOwnership, claimRouteExecutionProjection, RouteExecutionConflictError } from '../route-plans/route-execution-ownership.js'; import { ROUTE_ACTIVE_COMPATIBILITY_STATUSES, ROUTE_READY_COMPATIBILITY_STATUSES } from '../route-plans/route-plan-lifecycle.js'; import { readRouteStopPoints } from '../route-plans/route-plan-geometry-cache.js'; @@ -330,8 +331,19 @@ export class PrismaDriverEventRepository { } await lockRoutePlanForSerializedEvent(transaction, input); + // A simultaneous retry may have waited for the last-stop transaction. + // Recheck under its lock before the newly completed-work guard runs. + if (input.shopDomain === KFOOD_DELIVERY_SHOP_DOMAIN + && ['STOP_DELIVERED', 'STOP_FAILED', 'ROUTE_COMPLETED'].includes(input.eventType)) { + const committed = await findMatchingDriverEvent(transaction, input); + if (committed !== null) return { + completionInvariant: null, duplicate: true, eventId: committed.id, + ...(isEtaSnapshotRecoveryEvent(input.eventType) + ? { etaSnapshot: await buildCurrentEtaSnapshotForDuplicate(transaction, input) } : {}) + }; + } await validateVersionedOrderedContract(transaction, input); - await validateDriverEventStateContext(transaction, input, input.shopId); + const deferCompletionForNavigation = await validateDriverEventStateContext(transaction, input, input.shopId, this.now()); const completionInvariant = await evaluateCompletionInvariant(transaction, input, this.completionInvariantMode); if (completionInvariant?.decision === 'REJECTED') { if (attemptId !== null) { @@ -374,12 +386,14 @@ export class PrismaDriverEventRepository { clientEventId: input.clientEventId, deliveryStopId: input.deliveryStopId, driverId: input.driverId, - eventType: input.eventType as never, + eventType: (deferCompletionForNavigation ? 'NOTE_ADDED' : input.eventType) as never, latitude: trackingPositionAccepted ? input.latitude : null, longitude: trackingPositionAccepted ? input.longitude : null, occurredAt: input.occurredAt, - payload: trackingPositionAccepted - ? persistedDriverEventPayload(input, completionInvariant) + payload: deferCompletionForNavigation + ? { schema: 'kfood_return_navigation_completion_ack_v1', requestedEventType: 'ROUTE_COMPLETED', + payload: persistedDriverEventPayload(input, completionInvariant) } + : trackingPositionAccepted ? persistedDriverEventPayload(input, completionInvariant) : { redacted: true, schema: 'driver_location_service_window_tombstone_v1' }, routePlanId: input.routePlanId, ...(input.driverContractVersion === undefined || input.driverContractVersion === null @@ -412,7 +426,7 @@ export class PrismaDriverEventRepository { } await applyDispatchChangeRequestAck(transaction, input, event.id, event.createdAt); - const etaResult = await applyDriverEventStateTransition( + const etaResult = deferCompletionForNavigation ? {} : await applyDriverEventStateTransition( transaction, schemaCapabilities, input, @@ -421,7 +435,13 @@ export class PrismaDriverEventRepository { routeVersionId, event.id ); - if (transaction.customerRouteNotificationFact !== undefined && transaction.shop !== undefined) { + if (input.shopDomain === KFOOD_DELIVERY_SHOP_DOMAIN + && (input.eventType === 'STOP_DELIVERED' || input.eventType === 'STOP_FAILED')) { + await reconcileKfoodDeliveryWorkCompletion(transaction, { + routePlanId: requireRoutePlanId(input), shopId: input.shopId, now: event.createdAt + }); + } + if (!deferCompletionForNavigation && transaction.customerRouteNotificationFact !== undefined && transaction.shop !== undefined) { await persistAutomaticCustomerEmailFacts(transaction, { deliveryStopId: input.deliveryStopId, driverEventId: event.id, @@ -663,6 +683,7 @@ async function lockRoutePlanForSerializedEvent( && input.eventType !== 'STOP_FAILED' && input.eventType !== 'LOCATION_UPDATED' && input.eventType !== 'ROUTE_COMPLETED' + && input.eventType !== 'ROUTE_PAUSED' ) { return; } @@ -1027,7 +1048,7 @@ async function findMatchingDriverEvent( if ( input.clientEventId === null - || (input.eventType !== 'ROUTE_COMPLETED' && input.eventType !== 'ROUTE_PAUSED') + || !['ROUTE_COMPLETED', 'ROUTE_PAUSED', 'STOP_DELIVERED', 'STOP_FAILED'].includes(input.eventType) ) { return null; } @@ -1096,7 +1117,7 @@ function driverEventContextMatchesInput( input: RecordDriverEventInput ): boolean { const baseContextMatches = ( - event.eventType === input.eventType + (event.eventType === input.eventType || isReturnNavigationCompletionAck(event, input)) && event.routePlanId === input.routePlanId && event.deliveryStopId === input.deliveryStopId ); @@ -1105,6 +1126,14 @@ function driverEventContextMatchesInput( return driverEventPayloadChangeRequestId(event.payload) === input.changeRequestId; } +function isReturnNavigationCompletionAck(event: ExistingDriverEventContext, input: RecordDriverEventInput): boolean { + if (input.shopDomain !== KFOOD_DELIVERY_SHOP_DOMAIN || input.eventType !== 'ROUTE_COMPLETED' + || event.eventType !== 'NOTE_ADDED' || event.payload === null || typeof event.payload !== 'object' + || Array.isArray(event.payload)) return false; + const payload = event.payload as Record; + return payload.schema === 'kfood_return_navigation_completion_ack_v1' && payload.requestedEventType === 'ROUTE_COMPLETED'; +} + function driverEventPayloadChangeRequestId(payload: unknown): string | null { if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null; const changeRequestId = (payload as Record).changeRequestId; @@ -1218,20 +1247,30 @@ function readCompletionSnapshotStopIds(snapshot: Prisma.JsonValue): string[] { async function validateDriverEventStateContext( prisma: DriverEventTransactionClient, input: RecordDriverEventInput, - shopId: string -): Promise { + shopId: string, + now: Date +): Promise { if (input.eventType === 'ROUTE_STARTED') { const routePlanId = requireRoutePlanId(input); await requireStartableOwnedRoutePlan(prisma, { driverId: input.driverId, routePlanId, - shopId + shopId, + includeDeliveryCompletion: input.shopDomain === KFOOD_DELIVERY_SHOP_DOMAIN }); - return; + return false; } const routePlanId = requireRoutePlanId(input); - const routePlan = await requireOwnedRoutePlan(prisma, { driverId: input.driverId, routePlanId, shopId }); + const routePlan = await requireOwnedRoutePlan(prisma, { driverId: input.driverId, routePlanId, shopId, + includeDeliveryCompletion: input.shopDomain === KFOOD_DELIVERY_SHOP_DOMAIN }); + if (hasDeliveryNavigationGraceExpired(routePlan, now)) { + throw new DriverEventScopeError('Return navigation access has expired'); + } + if (hasDeliveryWorkCompleted(routePlan) + && input.eventType !== 'LOCATION_UPDATED' && input.eventType !== 'ROUTE_COMPLETED' && input.eventType !== 'NOTE_ADDED') { + throw new DriverEventRouteNotInProgressError('Delivery work is complete; only return navigation remains available'); + } if ( routePlan.status !== 'IN_PROGRESS' && ( @@ -1271,6 +1310,7 @@ async function validateDriverEventStateContext( shopId }); } + return input.eventType === 'ROUTE_COMPLETED' && hasDeliveryWorkCompleted(routePlan); } async function applyDriverEventStateTransition( @@ -1917,10 +1957,13 @@ function normalizedInteger(value: number | null | undefined): number | null { async function requireStartableOwnedRoutePlan( prisma: DriverEventTransactionClient, - input: { driverId: string; routePlanId: string; shopId: string } + input: { driverId: string; routePlanId: string; shopId: string; includeDeliveryCompletion?: boolean } ): Promise { const routePlan = await prisma.routePlan.findFirst({ - select: { id: true }, + select: { id: true, ...(input.includeDeliveryCompletion === true ? { + status: true, assignmentGeneration: true, deliveryWorkCompletedAt: true, driverNavigationUntil: true, + deliveryWorkCompletedGeneration: true, deliveryWorkCompletedVersionId: true + } : {}) }, where: { driverId: input.driverId, driverEvents: { none: { eventType: 'ROUTE_COMPLETED' } }, @@ -1932,6 +1975,9 @@ async function requireStartableOwnedRoutePlan( if (routePlan === null) { throw new DriverEventScopeError('Completed or unavailable routes cannot be started'); } + if (routePlan.status !== undefined && hasDeliveryWorkCompleted({ ...routePlan, status: routePlan.status })) { + throw new DriverEventScopeError('Completed delivery work cannot be restarted'); + } const stops = await prisma.routePlanStop.findMany({ select: { deliveryStopId: true }, where: { routePlanId: input.routePlanId } }); try { await assertRouteDispatchOwnership(prisma, { @@ -1948,10 +1994,13 @@ async function requireStartableOwnedRoutePlan( async function requireOwnedRoutePlan( prisma: DriverEventTransactionClient, - input: { driverId: string; routePlanId: string; shopId: string } -): Promise<{ status: string }> { + input: { driverId: string; routePlanId: string; shopId: string; includeDeliveryCompletion?: boolean } +): Promise { const routePlan = await prisma.routePlan.findFirst({ - select: { id: true, status: true }, + select: { id: true, status: true, ...(input.includeDeliveryCompletion === true ? { + assignmentGeneration: true, deliveryWorkCompletedAt: true, driverNavigationUntil: true, + deliveryWorkCompletedGeneration: true, deliveryWorkCompletedVersionId: true + } : {}) }, where: { driverId: input.driverId, driverEvents: { none: { eventType: 'ROUTE_COMPLETED' } }, @@ -1964,7 +2013,7 @@ async function requireOwnedRoutePlan( throw new DriverEventScopeError('Driver route context is outside the authenticated driver scope'); } - return { status: routePlan.status }; + return routePlan; } async function requireOwnedRoutePlanStop( diff --git a/apps/delivery-api/src/modules/driver/driver-route-access.repository.ts b/apps/delivery-api/src/modules/driver/driver-route-access.repository.ts index 482d4a1a..df68fe64 100644 --- a/apps/delivery-api/src/modules/driver/driver-route-access.repository.ts +++ b/apps/delivery-api/src/modules/driver/driver-route-access.repository.ts @@ -4,6 +4,11 @@ import { ROUTE_DRIVER_OPERATIONAL_STATUSES, toRouteExecutionStatus } from '../route-plans/route-plan-lifecycle.js'; +import { + hasDeliveryNavigationGraceExpired, + KFOOD_DELIVERY_APP_ID, + KFOOD_DELIVERY_SHOP_DOMAIN +} from '../route-plans/kfood-delivery-completion.js'; import type { RouteGroupingChildDto, RouteGroupingDraftRouteInput, @@ -41,11 +46,16 @@ type DriverRoutePlanRecord = { status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED'; } | null; id: string; + deliveryWorkCompletedAt?: Date | null; + deliveryWorkCompletedGeneration?: bigint | null; + deliveryWorkCompletedVersionId?: string | null; + driverNavigationUntil?: Date | null; isStoreReviewData?: boolean; name: string; planDate: Date; routeGroupingChildVersions?: Array<{ id: string; publishedAt: Date | null }>; shop: { + appId: string; shopDomain: string; }; status: string; @@ -54,6 +64,9 @@ type DriverRoutePlanRecord = { const routePlanSelect = { assignmentGeneration: true, constraints: true, + deliveryWorkCompletedAt: true, + deliveryWorkCompletedGeneration: true, + deliveryWorkCompletedVersionId: true, driver: { select: { account: { select: { id: true, isStoreReviewAccount: true, status: true, tokenVersion: true } }, @@ -65,6 +78,7 @@ const routePlanSelect = { } }, id: true, + driverNavigationUntil: true, isStoreReviewData: true, name: true, planDate: true, @@ -74,11 +88,12 @@ const routePlanSelect = { take: 1, where: { status: 'CURRENT' as const, supersededAt: null } }, - shop: { select: { shopDomain: true } }, + shop: { select: { appId: true, shopDomain: true } }, status: true } as const; const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu; +const SHARED_SCOPE_PAGE_SIZE = 3; export class PrismaDriverRouteAccessRepository { constructor( @@ -107,7 +122,7 @@ export class PrismaDriverRouteAccessRepository { } }); - if (routePlan === null) { + if (routePlan === null || hasExpiredKfoodNavigation(routePlan, this.now())) { return { status: 'NOT_FOUND' }; } @@ -125,9 +140,17 @@ export class PrismaDriverRouteAccessRepository { } }); - const visiblePlans = routePlans.filter(plan => isVisibleDsvRoute({ shopDomain: plan.shop.shopDomain }, plan.id)); + const tenantVisiblePlans = routePlans.filter(plan => ( + isVisibleDsvRoute({ shopDomain: plan.shop.shopDomain }, plan.id) + )); // An existing excluded route must not cause a presentation read to manufacture a new standby route. - if (routePlans.length > 0 && visiblePlans.length === 0) return { status: 'NOT_FOUND' }; + if (routePlans.length > 0 && tenantVisiblePlans.length === 0) return { status: 'NOT_FOUND' }; + const visiblePlans = tenantVisiblePlans.filter(plan => !hasExpiredKfoodNavigation(plan, this.now())); + // An expired assignment still proves an authenticated driver relationship. Keep My Routes empty + // without manufacturing a standby assignment after the navigation grace ends. + if (tenantVisiblePlans.length > 0 && visiblePlans.length === 0) { + return { status: 'ROUTES_FOUND', routes: [] }; + } const routes = visiblePlans.flatMap((routePlan): DriverRouteAccessInvitedRoute[] => { const result = mapRoutePlan(routePlan, { accountId, routeContext: routePlan.id }); return result.status === 'INVITED' ? [result] : []; @@ -264,28 +287,46 @@ export class PrismaDriverRouteAccessRepository { return { status: 'NOT_FOUND' }; } - const routePlans = await this.prisma.routePlan.findMany({ - orderBy: [{ planDate: 'asc' }, { name: 'asc' }], - select: routePlanSelect, - take: 3, - where: { - ...visibleDsvRouteWhere(), - OR: [ - { status: 'IN_PROGRESS' }, - { - routeGroupingChildVersions: { - some: { publishedAt: { not: null }, status: 'CURRENT', supersededAt: null } + const now = this.now(); + const visibleRoutePlans: DriverRoutePlanRecord[] = []; + let cursorId: string | undefined; + let expiryPaginationActive = false; + while (true) { + const page = await this.prisma.routePlan.findMany({ + ...(cursorId === undefined ? {} : { cursor: { id: cursorId }, skip: 1 }), + orderBy: [{ planDate: 'asc' }, { name: 'asc' }, { id: 'asc' }], + select: routePlanSelect, + take: SHARED_SCOPE_PAGE_SIZE, + where: { + ...visibleDsvRouteWhere(), + OR: [ + { status: 'IN_PROGRESS' }, + { + routeGroupingChildVersions: { + some: { publishedAt: { not: null }, status: 'CURRENT', supersededAt: null } + } } - } - ], - constraints: { path: ['routeScope', 'routeScopeKey'], equals: input.routeContext }, - driver: { is: { accountId: input.accountId, authSubject: { not: null }, status: 'ACTIVE' } }, - driverEvents: { none: { eventType: 'ROUTE_COMPLETED' } }, - status: { in: [...ROUTE_DRIVER_OPERATIONAL_STATUSES] } + ], + constraints: { path: ['routeScope', 'routeScopeKey'], equals: input.routeContext }, + driver: { is: { accountId: input.accountId, authSubject: { not: null }, status: 'ACTIVE' } }, + driverEvents: { none: { eventType: 'ROUTE_COMPLETED' } }, + status: { in: [...ROUTE_DRIVER_OPERATIONAL_STATUSES] } + } + }); + const unexpiredPage = page.filter((routePlan) => !hasExpiredKfoodNavigation(routePlan, now)); + visibleRoutePlans.push(...unexpiredPage.filter(isDriverVisibleRoutePlan).slice(0, 3 - visibleRoutePlans.length)); + expiryPaginationActive ||= unexpiredPage.length !== page.length; + if ( + visibleRoutePlans.length >= 2 + || page.length < SHARED_SCOPE_PAGE_SIZE + || !expiryPaginationActive + ) { + break; } - }); - - const visibleRoutePlans = routePlans.filter(isDriverVisibleRoutePlan); + const nextCursorId = page.at(-1)?.id; + if (nextCursorId === undefined || nextCursorId === cursorId) break; + cursorId = nextCursorId; + } if (visibleRoutePlans.length === 0) { return { status: 'NOT_FOUND' }; @@ -382,6 +423,12 @@ function isDriverVisibleRoutePlan(routePlan: DriverRoutePlanRecord): boolean { ); } +function hasExpiredKfoodNavigation(routePlan: DriverRoutePlanRecord, now: Date): boolean { + return routePlan.shop.appId === KFOOD_DELIVERY_APP_ID + && routePlan.shop.shopDomain === KFOOD_DELIVERY_SHOP_DOMAIN + && hasDeliveryNavigationGraceExpired(routePlan, now); +} + function buildCompanyGuidance(routePlan: DriverRoutePlanRecord): DriverRouteAccessCompanyGuidance { const constraints = objectOrNull(routePlan.constraints); const shopDomain = normalizeDriverCommerceDomain(routePlan.shop.shopDomain); diff --git a/apps/delivery-api/src/modules/driver/driver-route-order.service.ts b/apps/delivery-api/src/modules/driver/driver-route-order.service.ts index 0b6cf92a..89b4ef12 100644 --- a/apps/delivery-api/src/modules/driver/driver-route-order.service.ts +++ b/apps/delivery-api/src/modules/driver/driver-route-order.service.ts @@ -7,6 +7,7 @@ import { replaceCurrentRouteGroupingChildVersion, syncRoutePlanStopsPreservingRows } from '../route-grouping/route-grouping.service.js'; +import { hasDeliveryWorkCompleted } from '../route-plans/kfood-delivery-completion.js'; type DriverRouteOrderPrisma = DsvAssignmentTransactionPort & Pick; @@ -93,7 +94,8 @@ export class PrismaDriverRouteOrderService implements DriverRouteOrderServiceCon }); if (routePlan === null || routePlan.driverId !== input.driverId) throw new DriverRouteOrderError('ROUTE_SCOPE_REJECTED'); if ( - routePlan.status === 'COMPLETED' + hasDeliveryWorkCompleted(routePlan) + || routePlan.status === 'COMPLETED' || routePlan.status === 'INCOMPLETE' || routePlan.status === 'CANCELLED' || routePlan.driverEvents.length > 0 diff --git a/apps/delivery-api/src/modules/driver/driver-self-service.repository.ts b/apps/delivery-api/src/modules/driver/driver-self-service.repository.ts index 6dc58f99..89770c9c 100644 --- a/apps/delivery-api/src/modules/driver/driver-self-service.repository.ts +++ b/apps/delivery-api/src/modules/driver/driver-self-service.repository.ts @@ -23,7 +23,8 @@ import { type UpdateDriverProfileInput } from './driver-self-service.types.js'; import { coerceIanaTimezone } from './driver-route-timezone.js'; -import { ROUTE_DRIVER_VISIBLE_STATUSES, toRouteExecutionStatus } from '../route-plans/route-plan-lifecycle.js'; +import { ROUTE_DRIVER_VISIBLE_STATUSES } from '../route-plans/route-plan-lifecycle.js'; +import { hasDeliveryWorkCompleted, toRouteDeliveryDisplayStatus } from '../route-plans/kfood-delivery-completion.js'; export type DriverSelfServicePrismaClient = Pick< PrismaClient, @@ -39,6 +40,11 @@ type ScopedDriverRecord = { }; type RouteProgressRecord = { + assignmentGeneration: bigint; + deliveryWorkCompletedAt: Date | null; + deliveryWorkCompletedGeneration: bigint | null; + deliveryWorkCompletedVersionId: string | null; + driverNavigationUntil: Date | null; driverEvents: { eventType: string; occurredAt: Date }[]; routeStops: { deliveryStop: { status: string } }[]; status: string; @@ -450,8 +456,10 @@ function toDriverProfile(driver: { function toRouteHistoryItem(routePlan: RoutePlanHistoryRecord): DriverRouteHistoryItem { const stopStatuses = routePlan.routeStops.map((stop) => stop.deliveryStop.status); + const completedAt = routePlan.driverEvents.find((event) => event.eventType === 'ROUTE_COMPLETED')?.occurredAt + ?? (hasDeliveryWorkCompleted(routePlan) ? routePlan.deliveryWorkCompletedAt : null); return { - completedAt: routePlan.driverEvents.find((event) => event.eventType === 'ROUTE_COMPLETED')?.occurredAt.toISOString() ?? null, + completedAt: completedAt?.toISOString() ?? null, completedStopCount: stopStatuses.filter((status) => status === 'DELIVERED').length, deliveryDate: formatDateOnly(routePlan.planDate), failedStopCount: stopStatuses.filter((status) => status === 'FAILED').length, @@ -466,7 +474,7 @@ function toRouteHistoryItem(routePlan: RoutePlanHistoryRecord): DriverRouteHisto } function toHistoryStatus(routePlan: RouteProgressRecord): DriverRouteHistoryStatus { - const executionStatus = toRouteExecutionStatus(routePlan.status); + const executionStatus = toRouteDeliveryDisplayStatus(routePlan); if (executionStatus === 'COMPLETED') return 'completed'; if (executionStatus === 'INCOMPLETE') return 'incomplete'; if (executionStatus === 'IN_PROGRESS') return 'active'; @@ -484,6 +492,7 @@ function hasRouteCompleted(routePlan: RouteProgressRecord): boolean { return routePlan.routeStops.length > 0 && routePlan.routeStops.every((stop) => TERMINAL_STOP_STATUSES.has(stop.deliveryStop.status)); } + function readCompanyDisplayName(value: unknown, shopDomain: string): string { const constraints = objectOrNull(value); return readString(constraints?.companyDisplayName) ?? displayNameFromShopDomain(shopDomain); diff --git a/apps/delivery-api/src/modules/driver/driver-token-access.repository.ts b/apps/delivery-api/src/modules/driver/driver-token-access.repository.ts index d12bb5c4..2c3e757e 100644 --- a/apps/delivery-api/src/modules/driver/driver-token-access.repository.ts +++ b/apps/delivery-api/src/modules/driver/driver-token-access.repository.ts @@ -6,6 +6,11 @@ import { ROUTE_DRIVER_VISIBLE_STATUSES, toRouteExecutionStatus } from '../route-plans/route-plan-lifecycle.js'; +import { + hasDeliveryNavigationGraceExpired, + KFOOD_DELIVERY_APP_ID, + KFOOD_DELIVERY_SHOP_DOMAIN +} from '../route-plans/kfood-delivery-completion.js'; export type DriverTokenAccessPrismaClient = Pick; @@ -35,7 +40,10 @@ export type DriverRouteAccessScope = { }; export class PrismaDriverTokenAccessRepository { - constructor(private readonly prisma: DriverTokenAccessPrismaClient) {} + constructor( + private readonly prisma: DriverTokenAccessPrismaClient, + private readonly now: () => Date = () => new Date() + ) {} async isDriverAccountAccessTokenActive(input: DriverAccountTokenAccessCheckInput): Promise { const account = await this.prisma.driverAccount.findUnique({ @@ -71,6 +79,10 @@ export class PrismaDriverTokenAccessRepository { const routePlan = await this.prisma.routePlan.findFirst({ select: { + assignmentGeneration: true, + deliveryWorkCompletedAt: true, + deliveryWorkCompletedGeneration: true, + deliveryWorkCompletedVersionId: true, driver: { select: { account: { select: { isStoreReviewAccount: true } }, @@ -78,6 +90,7 @@ export class PrismaDriverTokenAccessRepository { } }, id: true, + driverNavigationUntil: true, isStoreReviewData: true, routeGroupingChildVersions: { orderBy: { updatedAt: 'desc' as const }, @@ -85,7 +98,7 @@ export class PrismaDriverTokenAccessRepository { take: 1, where: { status: 'CURRENT' as const, supersededAt: null } }, - shop: { select: { id: true, shopDomain: true } }, + shop: { select: { appId: true, id: true, shopDomain: true } }, status: true }, where: { @@ -102,6 +115,9 @@ export class PrismaDriverTokenAccessRepository { if ( routePlan === null || + (routePlan.shop.appId === KFOOD_DELIVERY_APP_ID + && routePlan.shop.shopDomain === KFOOD_DELIVERY_SHOP_DOMAIN + && hasDeliveryNavigationGraceExpired(routePlan, this.now())) || routePlan.driver === null || routePlan.driver.accountId !== input.accountId || routePlan.driver.authSubject === null || diff --git a/apps/delivery-api/src/modules/route-grouping/route-grouping.service.ts b/apps/delivery-api/src/modules/route-grouping/route-grouping.service.ts index 99d67cdd..93e07ac3 100644 --- a/apps/delivery-api/src/modules/route-grouping/route-grouping.service.ts +++ b/apps/delivery-api/src/modules/route-grouping/route-grouping.service.ts @@ -21,6 +21,7 @@ import { } from '../route-plans/route-plan-geometry-cache.js'; import type { RouteGeometryCacheRead } from '../route-plans/route-plan-geometry-cache.js'; import { toRouteExecutionStatus } from '../route-plans/route-plan-lifecycle.js'; +import { toRouteDeliveryDisplayStatus } from '../route-plans/kfood-delivery-completion.js'; import { normalizeRouteEtaRange, normalizeRouteTotalAmount } from '../route-plans/route-plan-summary-normalization.js'; import type { RouteGeometryProvider } from '../route-plans/route-plan.service.js'; import type { RoutePlanDetail, RoutePlanRouteGeometry, RoutePlanRouteMetrics, RoutePlanRouteResult, RoutePlanRouteStopPoint } from '../route-plans/route-plan.types.js'; @@ -136,6 +137,9 @@ type CurrentOrderRouteVersionWriter = { }; type CurrentChildVersionReplacementWriter = CurrentOrderRouteVersionWriter & { + routePlan: { + updateMany(args: Prisma.RoutePlanUpdateManyArgs): Promise<{ count: number }>; + }; routeGroupingChildVersion: { create(args: Prisma.RouteGroupingChildVersionCreateArgs): Promise<{ id: string }>; updateMany(args: Prisma.RouteGroupingChildVersionUpdateManyArgs): Promise<{ count: number }>; @@ -340,6 +344,21 @@ export async function replaceCurrentRouteGroupingChildVersion( }, select: { id: true } }); + if (input.routePlanId !== null) { + await prisma.routePlan.updateMany({ + data: { + deliveryWorkCompletedAt: null, + deliveryWorkCompletedGeneration: null, + deliveryWorkCompletedVersionId: null, + driverNavigationUntil: null + }, + where: { + deliveryWorkCompletedVersionId: input.currentChildId, + id: input.routePlanId, + shopId: input.shopId + } + }); + } await rebindCurrentOrdersToRouteVersion(prisma, { groupingId: input.groupingId, nextRouteVersionId: nextChild.id, @@ -3594,6 +3613,10 @@ function groupingRoutesListSelect() { select: { constraints: true, createdAt: true, + assignmentGeneration: true, + deliveryWorkCompletedAt: true, + deliveryWorkCompletedGeneration: true, + deliveryWorkCompletedVersionId: true, driver: { select: { displayName: true } }, driverEvents: { orderBy: { occurredAt: 'desc' as const }, @@ -3602,6 +3625,7 @@ function groupingRoutesListSelect() { where: { eventType: { in: [DriverEventType.ROUTE_STARTED, DriverEventType.ROUTE_PAUSED, DriverEventType.ROUTE_COMPLETED] } } }, driverId: true, + driverNavigationUntil: true, id: true, name: true, planDate: true, @@ -5185,7 +5209,7 @@ function toRoutesListChildDto( routeMetrics, scheduledStartAt: readScheduledStartAt(routePlan.constraints), scheduledStartTimeZone: readScheduledStartTimeZone(routePlan.constraints), - status: toRouteExecutionStatus(routePlan.status, routePlan.driverEvents), + status: toRouteDeliveryDisplayStatus(routePlan), stopsCount: routePlan.routeStops.length, totalAmount: normalizeRouteTotalAmount(assignments.map(({ order }) => order)), updatedAt: routePlan.updatedAt.toISOString() @@ -5526,7 +5550,7 @@ function toMinimalRoutePlanSummary(routePlan: NonNullable order)), updatedAt: routePlan.updatedAt.toISOString() @@ -5535,7 +5559,7 @@ function toMinimalRoutePlanSummary(routePlan: NonNullable; status: string } | null; + routePlan: DeliveryDisplayRoute | null; status: string; supersededAt: Date | null; }>; @@ -5544,7 +5568,7 @@ export function deriveGroupingDisplayStatus(group: { if (group.status === 'CANCELLED') return 'CANCELLED'; const statuses = group.childVersions .filter((child) => isOperationalCurrentChild(child)) - .map((child) => toRouteExecutionStatus(child.routePlan?.status, child.routePlan?.driverEvents)); + .map((child) => toRouteDeliveryDisplayStatus(child.routePlan ?? { status: 'READY' })); if (statuses.length > 0 && statuses.every((status) => status === 'COMPLETED')) return 'COMPLETED'; if (statuses.some((status) => status === 'IN_PROGRESS')) return 'IN_PROGRESS'; if (statuses.length > 0 && statuses.every((status) => status === 'COMPLETED' || status === 'INCOMPLETE')) { @@ -5555,11 +5579,21 @@ export function deriveGroupingDisplayStatus(group: { } function deriveChildDisplayStatus(child: { - routePlan: { driverEvents: Array<{ eventType: string }>; status: string } | null; + routePlan: DeliveryDisplayRoute | null; }): RouteGroupingChildDisplayStatus { - return toRouteExecutionStatus(child.routePlan?.status, child.routePlan?.driverEvents); + return toRouteDeliveryDisplayStatus(child.routePlan ?? { status: 'READY' }); } +type DeliveryDisplayRoute = { + assignmentGeneration?: bigint; + deliveryWorkCompletedAt?: Date | null; + deliveryWorkCompletedGeneration?: bigint | null; + deliveryWorkCompletedVersionId?: string | null; + driverEvents?: Array<{ eventType: string }>; + driverNavigationUntil?: Date | null; + status: string; +}; + function normalizeNotificationStatus(status: string): RouteGroupingNotificationStatus { if (status === 'SENT' || status === 'FAILED' || status === 'PENDING') return status; return 'NOT_REQUIRED'; diff --git a/apps/delivery-api/src/modules/route-plans/kfood-delivery-completion.ts b/apps/delivery-api/src/modules/route-plans/kfood-delivery-completion.ts new file mode 100644 index 00000000..9ffbc8ac --- /dev/null +++ b/apps/delivery-api/src/modules/route-plans/kfood-delivery-completion.ts @@ -0,0 +1,134 @@ +import type { Prisma } from '@prisma/client'; +import { toRouteExecutionStatus, type RouteExecutionStatus } from './route-plan-lifecycle.js'; + +export const KFOOD_DELIVERY_APP_ID = 'clever-route-kfood'; +export const KFOOD_DELIVERY_SHOP_DOMAIN = '7hrud1-xq.myshopify.com'; +export const KFOOD_RETURN_NAVIGATION_GRACE_MS = 2 * 60 * 60_000; +const TERMINAL_STOPS = new Set(['CANCELLED', 'DELIVERED', 'FAILED', 'SKIPPED']); + +export type DeliveryWorkCompletionRecord = { + assignmentGeneration?: bigint; + deliveryWorkCompletedAt?: Date | null; + deliveryWorkCompletedGeneration?: bigint | null; + deliveryWorkCompletedVersionId?: string | null; + driverNavigationUntil?: Date | null; + driverEvents?: readonly { eventType: string }[]; + status: string; +}; + +export function hasDeliveryWorkCompleted(route: DeliveryWorkCompletionRecord): boolean { + return route.assignmentGeneration !== undefined + && route.assignmentGeneration > 0n + && route.deliveryWorkCompletedGeneration === route.assignmentGeneration + && route.deliveryWorkCompletedAt instanceof Date + && Number.isFinite(route.deliveryWorkCompletedAt.getTime()) + && route.driverNavigationUntil instanceof Date + && route.driverNavigationUntil.getTime() - route.deliveryWorkCompletedAt.getTime() === KFOOD_RETURN_NAVIGATION_GRACE_MS + && typeof route.deliveryWorkCompletedVersionId === 'string' + && route.deliveryWorkCompletedVersionId.length > 0; +} + +// Admin/Shopify sees delivery work complete while legacy mobile access retains +// the raw IN_PROGRESS execution state solely for the return-navigation grace. +export function toRouteDeliveryDisplayStatus(route: DeliveryWorkCompletionRecord): RouteExecutionStatus { + if (route.status === 'IN_PROGRESS' && hasDeliveryWorkCompleted(route)) return 'COMPLETED'; + return toRouteExecutionStatus(route.status, route.driverEvents); +} + +export function hasDeliveryNavigationGraceExpired(route: DeliveryWorkCompletionRecord, now: Date): boolean { + return (route.status === 'IN_PROGRESS' || route.status === 'COMPLETED') && hasDeliveryWorkCompleted(route) + && route.driverNavigationUntil!.getTime() <= now.getTime(); +} + +type CompletionTx = Pick; +export type DeliveryWorkCompletion = { + assignmentGeneration: bigint; + completedAt: Date; + navigationUntil: Date; + routeVersionId: string; +}; + +// Caller holds the route lock used by driver ingestion. This is also used by +// the finalizer to revalidate a marker; it never manufactures stop outcomes. +export async function reconcileKfoodDeliveryWorkCompletion( + tx: CompletionTx, + input: { routePlanId: string; shopId: string; now: Date; allowStart?: boolean } +): Promise { + const route = await tx.routePlan.findFirst({ + where: { + id: input.routePlanId, shopId: input.shopId, status: 'IN_PROGRESS', + shop: { appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN } + }, + select: { + id: true, assignmentGeneration: true, deliveryWorkCompletedAt: true, driverNavigationUntil: true, + deliveryWorkCompletedGeneration: true, deliveryWorkCompletedVersionId: true, status: true, + routeGroupingChildVersions: { + where: { status: 'CURRENT', supersededAt: null }, take: 2, + select: { id: true, snapshot: true } + }, + routeStops: { + orderBy: { sequence: 'asc' }, + select: { + sequence: true, deliveryStopId: true, + deliveryStop: { select: { status: true, orderId: true, order: { select: { currentRouteVersionId: true } } } } + } + } + } + }); + if (route === null) return null; + const version = route.routeGroupingChildVersions.length === 1 ? route.routeGroupingChildVersions[0] : undefined; + const resolved = version !== undefined && isResolvedCurrentMembership(version.snapshot, version.id, route.routeStops); + const existing = hasDeliveryWorkCompleted(route) && route.deliveryWorkCompletedVersionId === version?.id; + if (!resolved || (!existing && input.allowStart === false)) { + if (route.deliveryWorkCompletedAt != null || route.driverNavigationUntil != null + || route.deliveryWorkCompletedGeneration != null || route.deliveryWorkCompletedVersionId != null) { + await tx.routePlan.updateMany({ + where: { id: route.id, shopId: input.shopId, status: 'IN_PROGRESS', assignmentGeneration: route.assignmentGeneration }, + data: { deliveryWorkCompletedAt: null, driverNavigationUntil: null, + deliveryWorkCompletedGeneration: null, deliveryWorkCompletedVersionId: null } + }); + } + return null; + } + if (existing) return { + assignmentGeneration: route.assignmentGeneration, completedAt: route.deliveryWorkCompletedAt!, + navigationUntil: route.driverNavigationUntil!, routeVersionId: version.id + }; + const navigationUntil = new Date(input.now.getTime() + KFOOD_RETURN_NAVIGATION_GRACE_MS); + const updated = await tx.routePlan.updateMany({ + where: { id: route.id, shopId: input.shopId, status: 'IN_PROGRESS', assignmentGeneration: route.assignmentGeneration }, + data: { + deliveryWorkCompletedAt: input.now, driverNavigationUntil: navigationUntil, + deliveryWorkCompletedGeneration: route.assignmentGeneration, deliveryWorkCompletedVersionId: version.id + } + }); + return updated.count === 1 ? { + assignmentGeneration: route.assignmentGeneration, completedAt: input.now, navigationUntil, routeVersionId: version.id + } : null; +} + +type CompletionStop = { sequence: number; deliveryStopId: string; deliveryStop: { + status: string; orderId: string; order: { currentRouteVersionId: string | null } +} }; + +function isResolvedCurrentMembership(snapshot: unknown, versionId: string, stops: CompletionStop[]): boolean { + if (snapshot === null || typeof snapshot !== 'object' || Array.isArray(snapshot) || stops.length === 0) return false; + const record = snapshot as Record; + if (record.membershipSchemaVersion !== undefined && record.membershipSchemaVersion !== 1) return false; + if (!Array.isArray(record.stops) || record.stops.length !== stops.length) return false; + const members = record.stops; + const stopIds = new Set(); + const orderIds = new Set(); + return stops.every((stop, index) => { + const member = members[index] as unknown; + if (member === null || typeof member !== 'object' || Array.isArray(member)) return false; + const row = member as Record; + if (stopIds.has(stop.deliveryStopId) || orderIds.has(stop.deliveryStop.orderId)) return false; + stopIds.add(stop.deliveryStopId); + orderIds.add(stop.deliveryStop.orderId); + return stop.sequence === index + 1 && row.sequence === stop.sequence + && row.deliveryStopId === stop.deliveryStopId && row.orderId === stop.deliveryStop.orderId + && stop.deliveryStop.order.currentRouteVersionId === versionId + && TERMINAL_STOPS.has(stop.deliveryStop.status); + }); +} diff --git a/apps/delivery-api/src/modules/route-plans/route-plan.repository.ts b/apps/delivery-api/src/modules/route-plans/route-plan.repository.ts index cf939ccd..52f3934c 100644 --- a/apps/delivery-api/src/modules/route-plans/route-plan.repository.ts +++ b/apps/delivery-api/src/modules/route-plans/route-plan.repository.ts @@ -55,6 +55,7 @@ import { routeGeometryCacheUpsertArgs } from './route-plan-geometry-cache.js'; import { isRouteReadyStatus, toRouteExecutionStatus } from './route-plan-lifecycle.js'; +import { reconcileKfoodDeliveryWorkCompletion, toRouteDeliveryDisplayStatus } from './kfood-delivery-completion.js'; import { normalizeRouteEtaRange, normalizeRouteTotalAmount } from './route-plan-summary-normalization.js'; import type { RouteGeometryCacheRead, RouteGeometryCacheWrite } from './route-plan-geometry-cache.js'; import type { RoutePlanRepository } from './route-plan.service.js'; @@ -108,11 +109,15 @@ type RoutePlanRecord = { createdAt: Date; constraints?: unknown; deliveryDate?: Date | null; + deliveryWorkCompletedAt?: Date | null; + deliveryWorkCompletedGeneration?: bigint | null; + deliveryWorkCompletedVersionId?: string | null; depotLatitude: unknown; depotLongitude: unknown; driver?: RoutePlanDriverRecord | null; driverEvents?: Array<{ eventType: string }>; driverId?: string | null; + driverNavigationUntil?: Date | null; driverRouteNotificationAttempts?: Array<{ createdAt: Date }>; id: string; metrics: unknown; @@ -259,6 +264,14 @@ export class PrismaRoutePlanRepository implements RoutePlanRepository { }); if (shop === null) return { duplicate: false as const, found: false as const }; + await tx.$queryRaw` + SELECT "id" + FROM "route_plans" + WHERE "id" = ${input.routePlanId}::uuid + AND "shopId" = ${shop.id}::uuid + FOR UPDATE + `; + const routeStop = await tx.routePlanStop.findFirst({ select: { deliveryStop: { select: { completionAssistanceCandidateId: true, order: { select: { email: true, sellerOrderSourceKind: true, sourcePlatform: true } }, orderId: true, status: true } }, @@ -422,6 +435,13 @@ export class PrismaRoutePlanRepository implements RoutePlanRepository { } }); + await reconcileKfoodDeliveryWorkCompletion(tx, { + allowStart: input.payload.status === 'COMPLETED', + now: new Date(), + routePlanId: input.routePlanId, + shopId: shop.id + }); + return { duplicate: false as const, found: true as const, @@ -2776,10 +2796,14 @@ async function collapseRouteGroupingSplitAfterChildDelete( function routePlanListSelect() { return { + assignmentGeneration: true, constraints: true, createdAt: true, depotLatitude: true, depotLongitude: true, + deliveryWorkCompletedAt: true, + deliveryWorkCompletedGeneration: true, + deliveryWorkCompletedVersionId: true, driver: { select: { _count: { select: { driverEvents: true } }, @@ -2795,6 +2819,7 @@ function routePlanListSelect() { }, driverEvents: routeLifecycleEventQuery(), driverId: true, + driverNavigationUntil: true, id: true, metrics: true, name: true, @@ -3068,7 +3093,7 @@ function toRoutePlanListSummary(routePlan: RoutePlanListRecord): RoutePlanSummar routeMetrics: cache?.shapeSignature === shapeSignature ? readRouteMetrics(cache.metrics) : null, scheduledStartAt: readScheduledStartAt(routePlan.constraints), scheduledStartTimeZone: readScheduledStartTimeZone(routePlan.constraints), - status: toRouteExecutionStatus(routePlan.status, routePlan.driverEvents), + status: toRouteDeliveryDisplayStatus(routePlan), stopsCount: readFiniteNumber(metrics?.stopsCount) ?? routePlan.routeStops.length, totalAmount: normalizeRouteTotalAmount(routePlan.routeStops.map(({ deliveryStop }) => deliveryStop.order)), updatedAt: routePlan.updatedAt.toISOString() @@ -3109,7 +3134,7 @@ function toRoutePlanSummary(routePlan: RoutePlanRecord, inputOrders?: RoutePlanO scheduledStartTimeZone: readScheduledStartTimeZone(routePlan.constraints), routeGroupingChild: toRouteGroupingChildSummary(routePlan.routeGroupingChildVersions), routeMetrics, - status: toRouteExecutionStatus(routePlan.status, routePlan.driverEvents), + status: toRouteDeliveryDisplayStatus(routePlan), stopsCount: metrics.stopsCount, totalAmount: normalizeRouteTotalAmount((routePlan.routeStops ?? []).map(({ deliveryStop }) => deliveryStop.order)), updatedAt: routePlan.updatedAt.toISOString() diff --git a/apps/delivery-api/src/modules/route-plans/stale-route-finalization.service.ts b/apps/delivery-api/src/modules/route-plans/stale-route-finalization.service.ts index 1c71c74b..2e4f851f 100644 --- a/apps/delivery-api/src/modules/route-plans/stale-route-finalization.service.ts +++ b/apps/delivery-api/src/modules/route-plans/stale-route-finalization.service.ts @@ -1,6 +1,7 @@ import type { PrismaClient } from '@prisma/client'; import { resolveRouteTrackingEventWindow } from '../route-tracking/route-tracking.event-window.js'; +import { reconcileKfoodDeliveryWorkCompletion } from './kfood-delivery-completion.js'; export const KFOOD_STALE_ROUTE_APP_ID = 'clever-route-kfood'; export const KFOOD_STALE_ROUTE_SHOP_DOMAIN = '7hrud1-xq.myshopify.com'; @@ -74,6 +75,55 @@ export class PrismaStaleRouteFinalizationService { `; if (locked[0] === undefined) return 'skippedConcurrent'; + const completion = await reconcileKfoodDeliveryWorkCompletion(tx, { + allowStart: false, + now, + routePlanId, + shopId + }); + if (completion !== null) { + if (completion.navigationUntil.getTime() > now.getTime()) return 'skippedNotDue'; + + const route = await tx.routePlan.findFirst({ + select: { + assignmentGeneration: true, + deliveryWorkCompletedAt: true, + deliveryWorkCompletedGeneration: true, + deliveryWorkCompletedVersionId: true, + driverNavigationUntil: true, + id: true, + shopId: true, + updatedAt: true + }, + where: { + id: routePlanId, + shopId, + shop: { + appId: KFOOD_STALE_ROUTE_APP_ID, + shopDomain: KFOOD_STALE_ROUTE_SHOP_DOMAIN + }, + status: 'IN_PROGRESS' + } + }); + if (route === null) return 'skippedConcurrent'; + + const updated = await tx.routePlan.updateMany({ + data: { status: 'COMPLETED' }, + where: { + assignmentGeneration: completion.assignmentGeneration, + deliveryWorkCompletedAt: completion.completedAt, + deliveryWorkCompletedGeneration: completion.assignmentGeneration, + deliveryWorkCompletedVersionId: completion.routeVersionId, + driverNavigationUntil: completion.navigationUntil, + id: route.id, + shopId: route.shopId, + status: 'IN_PROGRESS', + updatedAt: route.updatedAt + } + }); + return updated.count === 1 ? 'finalized' : 'skippedConcurrent'; + } + const route = await tx.routePlan.findFirst({ select: { assignmentGeneration: true, diff --git a/apps/delivery-api/src/modules/route-tracking/route-operational-state.service.ts b/apps/delivery-api/src/modules/route-tracking/route-operational-state.service.ts index 363cd528..9ff0ecd3 100644 --- a/apps/delivery-api/src/modules/route-tracking/route-operational-state.service.ts +++ b/apps/delivery-api/src/modules/route-tracking/route-operational-state.service.ts @@ -6,6 +6,7 @@ import { occurredAtWithinRouteTrackingEventWindow, resolveRouteTrackingEventWindow, } from './route-tracking.event-window.js'; +import { toRouteDeliveryDisplayStatus } from '../route-plans/kfood-delivery-completion.js'; export type RouteOperationalStateV1 = { activeAlerts: OperationalAlertDto[]; @@ -16,6 +17,8 @@ export type RouteOperationalStateV1 = { totalStopCount: number; } | null; observedAt: string; + deliveryWorkCompletedAt?: string | null; + driverNavigationUntil?: string | null; physicalPosition: { accuracyMeters: number | null; distanceMeters: number | null; @@ -60,7 +63,7 @@ export class PrismaRouteOperationalStateService { include: { driverEvents: { orderBy: [{ occurredAt: 'asc' }, { createdAt: 'asc' }, { id: 'asc' }], - select: { occurredAt: true }, + select: { eventType: true, occurredAt: true }, take: 1, where: { eventType: 'ROUTE_STARTED' }, }, @@ -103,7 +106,7 @@ export class PrismaRouteOperationalStateService { function usableTrackingGeometry; + driverEvents?: Array<{ eventType: string; occurredAt: Date }>; planDate: Date; trackingGeometry: { lastOccurredAt: Date } | null; }>(route: T): T['trackingGeometry'] { @@ -111,7 +114,7 @@ function usableTrackingGeometry event.eventType === 'ROUTE_STARTED')?.occurredAt, }); return eventWindow === null || occurredAtWithinRouteTrackingEventWindow(eventWindow, route.trackingGeometry.lastOccurredAt) @@ -125,7 +128,12 @@ function toOperationalState(input: { positionPayload: unknown; route: { constraints: unknown; - driverEvents?: Array<{ occurredAt: Date }>; + assignmentGeneration: bigint; + deliveryWorkCompletedAt: Date | null; + deliveryWorkCompletedGeneration: bigint | null; + deliveryWorkCompletedVersionId: string | null; + driverEvents?: Array<{ eventType: string; occurredAt: Date }>; + driverNavigationUntil: Date | null; driverRouteSessionLeases: Array<{ syncSession: { heartbeats: Array<{ completedStopCount: number | null; currentStopSequence: number | null; @@ -161,9 +169,11 @@ function toOperationalState(input: { totalStopCount: latestHeartbeat.totalStopCount }, observedAt: input.now.toISOString(), + deliveryWorkCompletedAt: route.deliveryWorkCompletedAt?.toISOString() ?? null, + driverNavigationUntil: route.driverNavigationUntil?.toISOString() ?? null, physicalPosition, routePlanId: route.id, - routeStatus: route.status, + routeStatus: toRouteDeliveryDisplayStatus({ ...route, driverEvents: [] }), serverProgress: { deliveredStopCount: delivered.length, failedStopCount: failed.length, diff --git a/apps/delivery-api/src/modules/shopify/order-sync.repository.ts b/apps/delivery-api/src/modules/shopify/order-sync.repository.ts index ebb80b4a..6c215bf3 100644 --- a/apps/delivery-api/src/modules/shopify/order-sync.repository.ts +++ b/apps/delivery-api/src/modules/shopify/order-sync.repository.ts @@ -30,6 +30,7 @@ import { ordersV2Where, prepareOrdersV2Filters, v2ProgressForRecord, type Orders import { orderedDateBoundary } from './ordered-date-range.js'; import { appScopedShopWhere, normalizeShopifyAppId } from "./shopify-app-scope.js"; import { isRouteReadyStatus } from "../route-plans/route-plan-lifecycle.js"; +import { reconcileKfoodDeliveryWorkCompletion } from "../route-plans/kfood-delivery-completion.js"; import { assertShopifyShopPrivacyWriteAllowed, lockShopifyOrderPrivacyIdentity, @@ -38,6 +39,7 @@ import { import { isPickupComplete, pickupCompleteAfter, torontoDateOnly } from './pickup-order-completion.js'; const SHOPIFY_UNFULFILLED_STATUSES = ["UNFULFILLED", "OPEN", "RESTOCKED"]; +const TERMINAL_DELIVERY_STOP_STATUSES = new Set(["CANCELLED", "DELIVERED", "FAILED", "SKIPPED"]); export type OrderSyncReason = "orders_page_open" | "manual_refresh" | "route_create_preflight"; @@ -238,6 +240,7 @@ type OrderSyncPrismaClient = Pick< | "order" | "orderItem" | "orderDeliveryFact" + | "routePlan" | "shopifyOrderRedactionTombstone" | "shopifyWebhookEvent" | "shop" @@ -245,7 +248,7 @@ type OrderSyncPrismaClient = Pick< type OrderSyncWriteClient = Pick< PrismaClient, - "$queryRaw" | "deliveryStop" | "inventory" | "inventoryEvent" | "inventoryOrder" | "order" | "orderItem" | "orderDeliveryFact" | "shopifyOrderRedactionTombstone" | "shopifyWebhookEvent" + "$queryRaw" | "deliveryStop" | "inventory" | "inventoryEvent" | "inventoryOrder" | "order" | "orderItem" | "orderDeliveryFact" | "routePlan" | "shopifyOrderRedactionTombstone" | "shopifyWebhookEvent" >; export type OrderSyncNotificationLogger = { @@ -720,14 +723,68 @@ export class PrismaOrderSyncRepository { const orderIds = [...new Set(input.orderIds)]; const orders = await this.prisma.order.findMany({ - select: { id: true, rawPayload: true }, + select: { + deliveryStops: { + select: { + routePlanStops: { select: { routePlanId: true } }, + }, + }, + id: true, + rawPayload: true, + }, where: { id: { in: orderIds }, shopId: shop.id }, }); if (orders.length === 0) return []; await this.prisma.$transaction(async (tx) => { if (input.field === "state") { - for (const order of orders) { + const currentMemberships = await tx.order.findMany({ + select: { + deliveryStops: { + select: { + routePlanStops: { select: { routePlanId: true } }, + }, + }, + id: true, + rawPayload: true, + }, + where: { id: { in: orderIds }, shopId: shop.id }, + }); + const routePlanIds = routePlanIdsForStatusPatch(currentMemberships); + for (const routePlanId of routePlanIds) { + await tx.$queryRaw` + SELECT "id" + FROM "route_plans" + WHERE "id" = ${routePlanId}::uuid + AND "shopId" = ${shop.id}::uuid + FOR UPDATE + `; + } + for (const orderId of [...orderIds].sort()) { + await tx.$queryRaw` + SELECT "id" + FROM "orders" + WHERE "id" = ${orderId}::uuid + AND "shopId" = ${shop.id}::uuid + FOR UPDATE + `; + } + const revalidatedOrders = await tx.order.findMany({ + select: { + deliveryStops: { + select: { + routePlanStops: { select: { routePlanId: true } }, + }, + }, + id: true, + rawPayload: true, + }, + where: { id: { in: orderIds }, shopId: shop.id }, + }); + if (!sameStringArray(routePlanIds, routePlanIdsForStatusPatch(revalidatedOrders))) { + throw new OrderSyncRouteLockedError("Order route membership changed during status correction."); + } + for (const order of revalidatedOrders) { await tx.order.update({ data: { rawPayload: toJson({ @@ -748,6 +805,16 @@ export class PrismaOrderSyncRepository { where: { shopId_orderId: { orderId: order.id, shopId: shop.id } }, }); } + const now = this.options.now?.() ?? new Date(); + const allowStart = TERMINAL_DELIVERY_STOP_STATUSES.has(input.value as BulkOrderStateValue); + for (const routePlanId of routePlanIds) { + await reconcileKfoodDeliveryWorkCompletion(tx, { + allowStart, + now, + routePlanId, + shopId: shop.id, + }); + } return; } @@ -1583,6 +1650,18 @@ async function findExistingOrderForSync( }); } +function routePlanIdsForStatusPatch(orders: Array<{ + deliveryStops: Array<{ routePlanStops: Array<{ routePlanId: string }> }>; +}>): string[] { + return [...new Set(orders.flatMap((order) => + order.deliveryStops.flatMap((stop) => stop.routePlanStops.map(({ routePlanId }) => routePlanId)), + ))].sort(); +} + +function sameStringArray(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + function isRetryableTransactionConflict(error: unknown): boolean { return objectOrNull(error)?.code === "P2034"; } diff --git a/apps/delivery-api/tests/completion-assistance.integration.test.ts b/apps/delivery-api/tests/completion-assistance.integration.test.ts index 4ed5494a..cca43316 100644 --- a/apps/delivery-api/tests/completion-assistance.integration.test.ts +++ b/apps/delivery-api/tests/completion-assistance.integration.test.ts @@ -62,6 +62,215 @@ describeDatabase('completion assistance PostgreSQL transactions', () => { return { shop, account, driver, route, version, stop, service, env, identity, candidate, register, response, setTime, stopState }; } + async function kfoodReturnFixture(options: { + finalStatus?: 'ARRIVED' | 'DELIVERED' | 'EN_ROUTE' | 'PENDING'; + firstStatus?: 'ARRIVED' | 'DELIVERED'; + shopDomain?: string; + appId?: string; + commandCoordinates?: boolean; + } = {}) { + const suffix = randomUUID(); + const appId = options.appId ?? 'clever-route-kfood'; + const shopDomain = options.shopDomain ?? '7hrud1-xq.myshopify.com'; + const shop = await prisma.shop.upsert({ + where: { appId_shopDomain: { appId, shopDomain } }, + create: { appId, shopDomain }, update: {} + }); + const account = await prisma.driverAccount.create({ data: { phone: `kfood-return-${suffix}` } }); + const driver = await prisma.driver.create({ data: { accountId: account.id, displayName: 'Kfood return intent', shopId: shop.id } }); + const route = await prisma.routePlan.create({ data: { + shopId: shop.id, driverId: driver.id, name: `Kfood return ${suffix}`, planDate: new Date(base), + constraints: {}, metrics: {}, optimizerVersion: 'integration', status: 'IN_PROGRESS' + } }); + const grouping = await prisma.routeGrouping.create({ data: { shopId: shop.id, name: `Kfood return ${suffix}`, planDate: new Date(base) } }); + const groupingVersion = await prisma.routeGroupingVersion.create({ data: { shopId: shop.id, groupingId: grouping.id, version: 1 } }); + const version = await prisma.routeGroupingChildVersion.create({ data: { + shopId: shop.id, groupingId: grouping.id, groupingVersionId: groupingVersion.id, + routePlanId: route.id, driverId: driver.id, version: 1, snapshot: {} + } }); + const stops = []; + const statuses = [options.firstStatus ?? 'DELIVERED', options.finalStatus ?? 'ARRIVED'] as const; + for (const [index, status] of statuses.entries()) { + const order = await prisma.order.create({ data: { + name: `#kfood-${suffix}-${index}`, rawPayload: {}, shopId: shop.id, + shopifyOrderGid: `gid://shopify/Order/kfood-${suffix}-${index}`, currentRouteVersionId: version.id + } }); + const stop = await prisma.deliveryStop.create({ data: { + orderId: order.id, shopId: shop.id, status, latitude: 37, longitude: 127, geocodeStatus: 'RESOLVED' + } }); + await prisma.routePlanStop.create({ data: { shopId: shop.id, routePlanId: route.id, deliveryStopId: stop.id, sequence: index + 1 } }); + stops.push({ ...stop, order }); + } + await prisma.routeGroupingChildVersion.update({ where: { id: version.id }, data: { snapshot: { + membershipSchemaVersion: 1, + stops: stops.map(({ id, order }, index) => ({ sequence: index + 1, deliveryStopId: id, orderId: order.id })) + } } }); + if (options.commandCoordinates === true) { + const policy = { + version: 'kfood_return_intent_only_v1', maxAccuracyMeters: 20, enterRadiusMeters: 50, exitRadiusMeters: 100, + dwellMs: 60_000, maxGapMs: 30_000, minDwellSamples: 3, ambiguityRadiusMeters: 200 + }; + await prisma.driverCompletionPolicy.createMany({ data: { version: policy.version, policy, createdAt: new Date(base) }, skipDuplicates: true }); + await prisma.driverCompletionRun.create({ data: { + id: randomUUID(), accountId: account.id, shopId: shop.id, driverId: driver.id, routePlanId: route.id, + assignmentGeneration: route.assignmentGeneration, expectedRouteVersionId: version.id, routeName: route.name, + stops: stops.map(({ id, status }, index) => ({ + deliveryStopId: id, status, coordinates: index === 0 ? { latitude: 43.65, longitude: -79.38 } : null + })), + policyVersions: [policy.version], activationId: null, createdAt: new Date(base) + } }); + } + const env = { + COMPLETION_ASSISTANCE_POLICY_JSON: 'null', COMPLETION_ASSISTANCE_ACCOUNT_IDS: '', + COMPLETION_ASSISTANCE_DETECTION_ENABLED: 'false', COMPLETION_ASSISTANCE_WORKER_ENABLED: 'false' + }; + const service = new PrismaCompletionAssistanceService(prisma, { env, now: () => new Date(base + 180_000) }); + const run = (await service.snapshot(account.id)).runs.find((item) => item.routePlanId === route.id); + return { + account, driver, env, route, service, shop, stops, version, run, + command: run === undefined ? null : { + kind: 'return_intent' as const, commandId: randomUUID(), runId: run.runId, routePlanId: route.id, + assignmentGeneration: run.assignmentGeneration, expectedRouteVersionId: version.id, occurredAt: iso(180_000) + } + }; + } + + test('K-food detection-off return intent completes only the final ARRIVED stop and replays without extending grace', async () => { + const f = await kfoodReturnFixture(); + expect(f.run).toMatchObject({ + routePlanId: f.route.id, + policy: { version: 'kfood_return_intent_only_v1' }, + stops: [{ coordinates: null }, { coordinates: null }] + }); + expect(f.command).not.toBeNull(); + const applied = await f.service.command(f.account.id, f.command!); + expect(applied).toMatchObject({ status: 'applied' }); + expect((await prisma.deliveryStop.findUniqueOrThrow({ where: { id: f.stops[1]!.id } })).status).toBe('DELIVERED'); + const completed = await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } }); + expect(completed).toMatchObject({ + status: 'IN_PROGRESS', deliveryWorkCompletedGeneration: f.route.assignmentGeneration, + deliveryWorkCompletedVersionId: f.version.id + }); + expect(completed.deliveryWorkCompletedAt?.toISOString()).toBe(iso(180_000)); + expect(completed.driverNavigationUntil?.toISOString()).toBe(iso(180_000 + 2 * 60 * 60_000)); + const returnEvent = await prisma.driverEvent.findFirst({ where: { + routePlanId: f.route.id, deliveryStopId: f.stops[1]!.id, eventType: 'STOP_DELIVERED', + clientEventId: `completion-assistance:return-intent:${f.command!.commandId}` + } }); + expect((returnEvent?.payload as Record | undefined)?.source).toBe('DRIVER_RETURN_INTENT'); + expect(await prisma.customerRouteNotificationFact.findUnique({ + where: { idempotencyKey: `completion-assistance:return-intent:${f.command!.commandId}` } + })).toMatchObject({ status: 'SKIPPED', requestedUiStatus: 'COMPLETED', errorCode: 'COMPLETION_ASSISTANCE_NOTIFICATIONS_DISABLED' }); + expect(await f.service.command(f.account.id, f.command!)).toEqual({ ...applied, status: 'duplicate' }); + expect((await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } })).driverNavigationUntil).toEqual(completed.driverNavigationUntil); + }); + + test('K-food all-terminal return intent only reconciles route completion', async () => { + const f = await kfoodReturnFixture(); + expect(f.command).not.toBeNull(); + await prisma.deliveryStop.update({ where: { id: f.stops[1]!.id }, data: { status: 'DELIVERED' } }); + expect(await f.service.command(f.account.id, f.command!)).toMatchObject({ status: 'applied' }); + expect((await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } })).deliveryWorkCompletedAt?.toISOString()).toBe(iso(180_000)); + expect(await prisma.driverEvent.count({ where: { + routePlanId: f.route.id, clientEventId: `completion-assistance:return-intent:${f.command!.commandId}` + } })).toBe(0); + }); + + test('K-food return-only run durably rejects crafted candidate and response commands without side effects', async () => { + const f = await kfoodReturnFixture(); + expect(f.run).toBeDefined(); + const candidateId = randomUUID(); + const candidate: CompletionCandidate = { + candidateId, runId: f.run!.runId, routePlanId: f.route.id, + assignmentGeneration: f.run!.assignmentGeneration, expectedRouteVersionId: f.version.id, + deliveryStopId: f.stops[1]!.id, arrivalAt: iso(60_000), dwellCompletedAt: iso(120_000), exitAt: iso(180_000), + evidence: [60_000, 120_000, 180_000].map((offset) => ({ + latitude: 43.65, longitude: -79.38, accuracyMeters: 5, occurredAt: iso(offset) + })), + policyVersion: 'kfood_return_intent_only_v1', status: 'awaiting_response', revision: 0 + }; + const candidateCommand: CompletionCommand = { + kind: 'candidate', commandId: randomUUID(), candidate, occurredAt: candidate.exitAt + }; + const responseCommand: CompletionCommand = { + kind: 'response', commandId: randomUUID(), candidateId, runId: f.run!.runId, routePlanId: f.route.id, + assignmentGeneration: f.run!.assignmentGeneration, expectedRouteVersionId: f.version.id, + deliveryStopId: f.stops[1]!.id, response: 'completed', expectedRevision: 0, occurredAt: iso(180_000) + }; + const beforeEvents = await prisma.driverEvent.count({ where: { routePlanId: f.route.id } }); + const beforeFacts = await prisma.customerRouteNotificationFact.count({ where: { routePlanId: f.route.id } }); + expect(await f.service.command(f.account.id, candidateCommand)).toMatchObject({ status: 'rejected', reason: 'return_intent_only' }); + expect(await f.service.command(f.account.id, responseCommand)).toMatchObject({ status: 'rejected', reason: 'return_intent_only' }); + expect(await prisma.driverCompletionCandidate.count({ where: { runId: f.run!.runId } })).toBe(0); + expect(await prisma.driverCompletionReceipt.count({ where: { + accountId: f.account.id, commandId: { in: [candidateCommand.commandId, responseCommand.commandId] } + } })).toBe(2); + expect(await prisma.driverEvent.count({ where: { routePlanId: f.route.id } })).toBe(beforeEvents); + expect(await prisma.customerRouteNotificationFact.count({ where: { routePlanId: f.route.id } })).toBe(beforeFacts); + expect((await prisma.deliveryStop.findUniqueOrThrow({ where: { id: f.stops[1]!.id } })).status).toBe('ARRIVED'); + }); + + test('K-food return-only coordinate-bearing run disables projection and still rejects crafted inference', async () => { + const f = await kfoodReturnFixture({ commandCoordinates: true }); + expect(f.run).toBeDefined(); + expect(f.run!.policy).toBeNull(); + const candidate: CompletionCandidate = { + candidateId: randomUUID(), runId: f.run!.runId, routePlanId: f.route.id, + assignmentGeneration: f.run!.assignmentGeneration, expectedRouteVersionId: f.version.id, + deliveryStopId: f.stops[1]!.id, arrivalAt: iso(60_000), dwellCompletedAt: iso(120_000), exitAt: iso(180_000), + evidence: [60_000, 120_000, 180_000].map((offset) => ({ + latitude: 43.65, longitude: -79.38, accuracyMeters: 5, occurredAt: iso(offset) + })), + policyVersion: 'kfood_return_intent_only_v1', status: 'awaiting_response', revision: 0 + }; + expect(await f.service.command(f.account.id, { + kind: 'candidate', commandId: randomUUID(), candidate, occurredAt: candidate.exitAt + })).toMatchObject({ status: 'rejected', reason: 'return_intent_only' }); + expect(await prisma.driverCompletionCandidate.count({ where: { runId: f.run!.runId } })).toBe(0); + expect((await prisma.deliveryStop.findUniqueOrThrow({ where: { id: f.stops[1]!.id } })).status).toBe('ARRIVED'); + }); + + test.each([ + { name: 'PENDING final stop', options: { finalStatus: 'PENDING' as const }, reason: 'final_arrival_not_ready' }, + { name: 'EN_ROUTE final stop', options: { finalStatus: 'EN_ROUTE' as const }, reason: 'final_arrival_not_ready' }, + { name: 'multiple unresolved stops', options: { firstStatus: 'ARRIVED' as const }, reason: 'final_arrival_not_ready' }, + { name: 'an earlier sole unresolved stop', options: { firstStatus: 'ARRIVED' as const, finalStatus: 'DELIVERED' as const }, reason: 'final_arrival_not_ready' } + ])('K-food return intent rejects $name', async ({ options, reason }) => { + const f = await kfoodReturnFixture(options); + expect(f.command).not.toBeNull(); + expect(await f.service.command(f.account.id, f.command!)).toMatchObject({ status: 'rejected', reason }); + expect((await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } })).deliveryWorkCompletedAt).toBeNull(); + }); + + test.each(['order-version', 'membership', 'current-version'] as const)('K-food return intent rejects changed %s ownership', async (change) => { + const f = await kfoodReturnFixture(); + expect(f.command).not.toBeNull(); + if (change === 'order-version') { + await prisma.order.update({ where: { id: f.stops[1]!.order.id }, data: { currentRouteVersionId: null } }); + } else if (change === 'membership') { + await prisma.routeGroupingChildVersion.update({ where: { id: f.version.id }, data: { snapshot: { + membershipSchemaVersion: 1, + stops: [{ sequence: 1, deliveryStopId: f.stops[0]!.id, orderId: f.stops[0]!.order.id }] + } } }); + } else { + await prisma.routeGroupingChildVersion.update({ where: { id: f.version.id }, data: { status: 'ARCHIVED', supersededAt: new Date(base + 181_000) } }); + await prisma.routeGroupingChildVersion.create({ data: { + shopId: f.shop.id, groupingId: f.version.groupingId, groupingVersionId: f.version.groupingVersionId, + routePlanId: f.route.id, driverId: f.driver.id, version: 2, snapshot: {} + } }); + } + expect(await f.service.command(f.account.id, f.command!)).toMatchObject({ + status: 'rejected', reason: change === 'current-version' ? 'run_invalidated' : 'route_membership_changed' + }); + expect((await prisma.deliveryStop.findUniqueOrThrow({ where: { id: f.stops[1]!.id } })).status).toBe('ARRIVED'); + }); + + test('null-policy run issuance remains scoped to the exact K-food tenant', async () => { + const foreign = await kfoodReturnFixture({ shopDomain: `foreign-${randomUUID()}.myshopify.com` }); + expect(foreign.run).toBeUndefined(); + expect(foreign.command).toBeNull(); + }); + test('exact 24h boundary, inference and consecutive offline corrections preserve real ARRIVED and immutable receipts', async () => { const f = await fixture(); const orderBefore = await prisma.order.findUniqueOrThrow({ where: { id: f.stop.orderId } }); diff --git a/apps/delivery-api/tests/driver-event.repository.test.ts b/apps/delivery-api/tests/driver-event.repository.test.ts index 9a41721b..d74bd176 100644 --- a/apps/delivery-api/tests/driver-event.repository.test.ts +++ b/apps/delivery-api/tests/driver-event.repository.test.ts @@ -870,7 +870,7 @@ describe('PrismaDriverEventRepository', () => { }, eventId: 'original-delivery-id' }); - expect(prisma.driverEvent.create).toHaveBeenCalledOnce(); + expect(prisma.driverEvent.create).not.toHaveBeenCalled(); expect(prisma.routePlanStop.update).not.toHaveBeenCalled(); expect(prisma.routePlanStop.updateMany).not.toHaveBeenCalled(); }); @@ -930,7 +930,7 @@ describe('PrismaDriverEventRepository', () => { }, eventId: 'original-failure-id' }); - expect(prisma.driverEvent.create).toHaveBeenCalledOnce(); + expect(prisma.driverEvent.create).not.toHaveBeenCalled(); }); test('returns duplicate pickup snapshot with missing duration without hydrating geometry cache', async () => { diff --git a/apps/delivery-api/tests/driver-route-access.repository.test.ts b/apps/delivery-api/tests/driver-route-access.repository.test.ts index 2920a3dc..582a063a 100644 --- a/apps/delivery-api/tests/driver-route-access.repository.test.ts +++ b/apps/delivery-api/tests/driver-route-access.repository.test.ts @@ -22,6 +22,9 @@ describe('PrismaDriverRouteAccessRepository', () => { select: { assignmentGeneration: true, constraints: true, + deliveryWorkCompletedAt: true, + deliveryWorkCompletedGeneration: true, + deliveryWorkCompletedVersionId: true, driver: { select: { account: { select: { id: true, isStoreReviewAccount: true, status: true, tokenVersion: true } }, @@ -33,6 +36,7 @@ describe('PrismaDriverRouteAccessRepository', () => { } }, id: true, + driverNavigationUntil: true, isStoreReviewData: true, name: true, planDate: true, @@ -42,7 +46,7 @@ describe('PrismaDriverRouteAccessRepository', () => { take: 1, where: { status: 'CURRENT', supersededAt: null } }, - shop: { select: { shopDomain: true } }, + shop: { select: { appId: true, shopDomain: true } }, status: true }, where: { @@ -110,6 +114,9 @@ describe('PrismaDriverRouteAccessRepository', () => { select: { assignmentGeneration: true, constraints: true, + deliveryWorkCompletedAt: true, + deliveryWorkCompletedGeneration: true, + deliveryWorkCompletedVersionId: true, driver: { select: { account: { select: { id: true, isStoreReviewAccount: true, status: true, tokenVersion: true } }, @@ -121,6 +128,7 @@ describe('PrismaDriverRouteAccessRepository', () => { } }, id: true, + driverNavigationUntil: true, isStoreReviewData: true, name: true, planDate: true, @@ -130,7 +138,7 @@ describe('PrismaDriverRouteAccessRepository', () => { take: 1, where: { status: 'CURRENT', supersededAt: null } }, - shop: { select: { shopDomain: true } }, + shop: { select: { appId: true, shopDomain: true } }, status: true }, where: { @@ -219,6 +227,68 @@ describe('PrismaDriverRouteAccessRepository', () => { }); }); + test('keeps delivery-complete navigation visible until the exact two-hour boundary', async () => { + const completedAt = new Date('2026-10-01T16:00:00.000Z'); + const routePlan = routePlanRecord({ + appId: 'clever-route-kfood', + deliveryWorkCompletedAt: completedAt, + deliveryWorkCompletedGeneration: 1n, + deliveryWorkCompletedVersionId: '22222222-2222-4222-8222-222222222222', + driverNavigationUntil: new Date('2026-10-01T18:00:00.000Z'), + shopDomain: '7hrud1-xq.myshopify.com', + status: 'IN_PROGRESS' + }); + const beforeBoundary = new PrismaDriverRouteAccessRepository( + createPrismaHarness({ routePlan }).prisma as never, + undefined, + () => new Date('2026-10-01T17:59:59.999Z') + ); + const atBoundary = new PrismaDriverRouteAccessRepository( + createPrismaHarness({ routePlan }).prisma as never, + undefined, + () => new Date('2026-10-01T18:00:00.000Z') + ); + + await expect(beforeBoundary.lookupRouteAccess({ accountId: 'account-id', routeContext: routePlanId })) + .resolves.toMatchObject({ status: 'INVITED' }); + await expect(atBoundary.lookupRouteAccess({ accountId: 'account-id', routeContext: routePlanId })) + .resolves.toEqual({ status: 'NOT_FOUND' }); + }); + + test('hides an expired route from the account list but ignores stale markers from an older assignment', async () => { + const marker = { + deliveryWorkCompletedAt: new Date('2026-10-01T16:00:00.000Z'), + deliveryWorkCompletedVersionId: '22222222-2222-4222-8222-222222222222', + driverNavigationUntil: new Date('2026-10-01T18:00:00.000Z'), + status: 'IN_PROGRESS' + } as const; + const now = () => new Date('2026-10-01T18:00:00.000Z'); + const saveDraft = vi.fn(); + const expired = new PrismaDriverRouteAccessRepository(createPrismaHarness({ + phoneRoutePlans: [routePlanRecord({ + ...marker, appId: 'clever-route-kfood', deliveryWorkCompletedGeneration: 1n, + shopDomain: '7hrud1-xq.myshopify.com' + })] + }).prisma as never, { saveDraft } as never, now); + const reassigned = new PrismaDriverRouteAccessRepository(createPrismaHarness({ + phoneRoutePlans: [routePlanRecord({ + ...marker, appId: 'clever-route-kfood', assignmentGeneration: 2n, deliveryWorkCompletedGeneration: 1n, + shopDomain: '7hrud1-xq.myshopify.com' + })] + }).prisma as never, undefined, now); + const foreignTenant = new PrismaDriverRouteAccessRepository(createPrismaHarness({ + phoneRoutePlans: [routePlanRecord({ ...marker, deliveryWorkCompletedGeneration: 1n })] + }).prisma as never, undefined, now); + + await expect(expired.lookupRouteAccess({ accountId: 'account-id', routeContext: null })) + .resolves.toEqual({ status: 'ROUTES_FOUND', routes: [] }); + expect(saveDraft).not.toHaveBeenCalled(); + await expect(reassigned.lookupRouteAccess({ accountId: 'account-id', routeContext: null })) + .resolves.toMatchObject({ status: 'ROUTES_FOUND', routes: [{ status: 'INVITED' }] }); + await expect(foreignTenant.lookupRouteAccess({ accountId: 'account-id', routeContext: null })) + .resolves.toMatchObject({ status: 'ROUTES_FOUND', routes: [{ status: 'INVITED' }] }); + }); + test('excludes unpublished ready routes before resolving a shared-scope ambiguity', async () => { const publishedRouteId = '33333333-3333-4333-8333-333333333333'; const { prisma } = createPrismaHarness({ @@ -520,10 +590,13 @@ describe('PrismaDriverRouteAccessRepository', () => { }); expect(prisma.routePlan.findMany).toHaveBeenCalledWith({ - orderBy: [{ planDate: 'asc' }, { name: 'asc' }], + orderBy: [{ planDate: 'asc' }, { name: 'asc' }, { id: 'asc' }], select: { assignmentGeneration: true, constraints: true, + deliveryWorkCompletedAt: true, + deliveryWorkCompletedGeneration: true, + deliveryWorkCompletedVersionId: true, driver: { select: { account: { select: { id: true, isStoreReviewAccount: true, status: true, tokenVersion: true } }, @@ -535,6 +608,7 @@ describe('PrismaDriverRouteAccessRepository', () => { } }, id: true, + driverNavigationUntil: true, isStoreReviewData: true, name: true, planDate: true, @@ -544,7 +618,7 @@ describe('PrismaDriverRouteAccessRepository', () => { take: 1, where: { status: 'CURRENT', supersededAt: null } }, - shop: { select: { shopDomain: true } }, + shop: { select: { appId: true, shopDomain: true } }, status: true }, take: 3, @@ -594,6 +668,46 @@ describe('PrismaDriverRouteAccessRepository', () => { expect(JSON.stringify(result)).not.toContain('address1'); }); + test('pages past forty expired K-food routes without retaining them before finding a valid shared route', async () => { + const expiredMarker = { + appId: 'clever-route-kfood', + deliveryWorkCompletedAt: new Date('2026-10-01T16:00:00.000Z'), + deliveryWorkCompletedGeneration: 1n, + deliveryWorkCompletedVersionId: '22222222-2222-4222-8222-222222222222', + driverNavigationUntil: new Date('2026-10-01T18:00:00.000Z'), + shopDomain: '7hrud1-xq.myshopify.com', + status: 'IN_PROGRESS' + } as const; + const validRouteId = '55555555-5555-4555-8555-555555555555'; + const expiredRoutes = Array.from({ length: 40 }, (_, index) => routePlanRecord({ + ...expiredMarker, + id: `00000000-0000-4000-8000-${String(index + 1).padStart(12, '0')}` + })); + const sharedRoutePlanResponses = Array.from({ length: 13 }, (_, index) => ( + expiredRoutes.slice(index * 3, index * 3 + 3) + )); + sharedRoutePlanResponses.push([expiredRoutes[39]!, routePlanRecord({ id: validRouteId, name: 'Later valid route' })]); + const { prisma } = createPrismaHarness({ + sharedRoutePlanResponses + }); + const repository = new PrismaDriverRouteAccessRepository( + prisma as never, + undefined, + () => new Date('2026-10-01T18:00:00.000Z') + ); + + await expect(repository.lookupRouteAccess({ + accountId: 'account-id', routeContext: 'toronto-shared-route-scope' + })).resolves.toMatchObject({ status: 'INVITED', routeAccess: { routePlanId: validRouteId } }); + + expect(prisma.routePlan.findMany).toHaveBeenCalledTimes(14); + expect(prisma.routePlan.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ + cursor: { id: '00000000-0000-4000-8000-000000000039' }, + skip: 1, + take: 3 + })); + }); + test('maps one shared route scope match to invited route access', async () => { const { prisma } = createPrismaHarness({ sharedRoutePlans: [routePlanRecord({ id: '22222222-2222-4222-8222-222222222222' })] @@ -657,6 +771,7 @@ function createPrismaHarness( }>; routePlan?: ReturnType | null; sharedRoutePlans?: ReturnType[]; + sharedRoutePlanResponses?: Array[]>; phoneRoutePlans?: ReturnType[]; phoneRoutePlanResponses?: Array[]>; publicRouteContext?: { groupingId: string } | null; @@ -679,6 +794,7 @@ function createPrismaHarness( ? routePlanRecord(overrides.driverStatus === undefined ? {} : { driverStatus: overrides.driverStatus }) : overrides.routePlan; const phoneRoutePlanResponses = [...(overrides.phoneRoutePlanResponses ?? [])]; + const sharedRoutePlanResponses = [...(overrides.sharedRoutePlanResponses ?? [])]; return { prisma: { driver: { @@ -691,6 +807,8 @@ function createPrismaHarness( findMany: vi.fn((query?: unknown) => { const text = JSON.stringify(query); if (text.includes('routeScopeKey')) { + const response = sharedRoutePlanResponses.shift(); + if (response !== undefined) return Promise.resolve(response); return Promise.resolve(overrides.sharedRoutePlans ?? []); } @@ -707,8 +825,13 @@ function createPrismaHarness( function routePlanRecord( overrides: { assignmentGeneration?: bigint; + appId?: string; authSubject?: string | null; + deliveryWorkCompletedAt?: Date | null; + deliveryWorkCompletedGeneration?: bigint | null; + deliveryWorkCompletedVersionId?: string | null; driverStatus?: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED'; + driverNavigationUntil?: Date | null; id?: string; legacyContract?: boolean; name?: string; @@ -723,6 +846,10 @@ function routePlanRecord( const shopDomain = overrides.shopDomain ?? 'tomatono.myshopify.com'; return { ...(overrides.legacyContract === true ? {} : { assignmentGeneration: overrides.assignmentGeneration ?? 1n }), + deliveryWorkCompletedAt: overrides.deliveryWorkCompletedAt ?? null, + deliveryWorkCompletedGeneration: overrides.deliveryWorkCompletedGeneration ?? null, + deliveryWorkCompletedVersionId: overrides.deliveryWorkCompletedVersionId ?? null, + driverNavigationUntil: overrides.driverNavigationUntil ?? null, constraints: { companyDisplayName: shopDomain === 'north-market.myshopify.com' ? 'North Market' : 'Tomatono Toronto', driverInstructions: ['Bring insulated bag'], @@ -758,6 +885,7 @@ function routePlanRecord( }], }), shop: { + appId: overrides.appId ?? 'clever-route-shopify', shopDomain }, status: overrides.status ?? 'READY', diff --git a/apps/delivery-api/tests/driver-route-order.service.test.ts b/apps/delivery-api/tests/driver-route-order.service.test.ts index 74e295a7..310fa4ab 100644 --- a/apps/delivery-api/tests/driver-route-order.service.test.ts +++ b/apps/delivery-api/tests/driver-route-order.service.test.ts @@ -58,6 +58,7 @@ describe('PrismaDriverRouteOrderService', () => { ['missing stop', {}, { ...baseInput, orderedStopIds: ['stop-1'] }, 'INVALID_STOP_SET'], ['extra stop', {}, { ...baseInput, orderedStopIds: ['stop-1', 'stop-2', 'stop-3'] }, 'INVALID_STOP_SET'], ['other driver', { driverId: 'driver-2' }, baseInput, 'ROUTE_SCOPE_REJECTED'], + ['delivery-complete navigation grace', { deliveryWorkCompleted: true }, baseInput, 'ROUTE_COMPLETED'], ['completed route', { status: 'COMPLETED' }, baseInput, 'ROUTE_COMPLETED'] ])('rejects %s without replacing route authority', async (_description, options, input, code) => { const { prisma } = harness(options); @@ -78,7 +79,7 @@ describe('PrismaDriverRouteOrderService', () => { }); }); -type HarnessOptions = { child?: null; driverId?: string; status?: string }; +type HarnessOptions = { child?: null; deliveryWorkCompleted?: boolean; driverId?: string; status?: string }; function harness(options: HarnessOptions = {}) { const receipt: Record = {}; @@ -119,8 +120,17 @@ function harness(options: HarnessOptions = {}) { }, routePlan: { findFirst: vi.fn(() => Promise.resolve({ + assignmentGeneration: 2n, + deliveryWorkCompletedAt: options.deliveryWorkCompleted === true + ? new Date('2026-10-01T16:00:00.000Z') + : null, + deliveryWorkCompletedGeneration: options.deliveryWorkCompleted === true ? 2n : null, + deliveryWorkCompletedVersionId: options.deliveryWorkCompleted === true ? 'version-old' : null, driverEvents: [], driverId: options.driverId ?? 'driver-1', + driverNavigationUntil: options.deliveryWorkCompleted === true + ? new Date('2026-10-01T18:00:00.000Z') + : null, routeStops, status: options.status ?? 'IN_PROGRESS' })) diff --git a/apps/delivery-api/tests/driver-self-service.repository.test.ts b/apps/delivery-api/tests/driver-self-service.repository.test.ts index bfeb9547..04002d07 100644 --- a/apps/delivery-api/tests/driver-self-service.repository.test.ts +++ b/apps/delivery-api/tests/driver-self-service.repository.test.ts @@ -86,6 +86,36 @@ describe('PrismaDriverSelfServiceRepository', () => { expect(result.pageInfo).toEqual({ endCursor: anyStringMatcher, hasNextPage: false }); }); + test('uses the matching persisted completion time when no synthetic route-completed event exists', async () => { + const completedAt = new Date('2026-10-01T22:00:00.000Z'); + const { prisma } = createPrismaHarness({ routePlans: [routePlanRecord({ + assignmentGeneration: 7n, + deliveryWorkCompletedAt: completedAt, + deliveryWorkCompletedGeneration: 7n, + deliveryWorkCompletedVersionId: 'route-version-id', + driverEvents: [{ eventType: 'ROUTE_STARTED', occurredAt: new Date('2026-10-01T20:00:00.000Z') }], + driverNavigationUntil: new Date('2026-10-02T00:00:00.000Z'), + status: 'IN_PROGRESS' + })] }); + const repository = new PrismaDriverSelfServiceRepository(prisma as never); + + const result = await repository.listDriverRoutes({ + cursor: null, + driverId: 'driver-id', + from: null, + shopDomain: 'example.myshopify.com', + shopId: 'shop-id', + status: 'completed', + to: null + }); + + expect(result.routes).toEqual([expect.objectContaining({ + completedAt: completedAt.toISOString(), + routePlanId, + status: 'completed' + })]); + }); + test('resolves driver profiles for Woo customer domains', async () => { const { prisma } = createPrismaHarness({ shopDomain: 'dev1.tomatonofood.com' }); @@ -554,15 +584,25 @@ function routeHistoryIncludeMatcher(driverId: string): unknown { } function routePlanRecord(input: { + assignmentGeneration?: bigint; constraints?: unknown; + deliveryWorkCompletedAt?: Date | null; + deliveryWorkCompletedGeneration?: bigint | null; + deliveryWorkCompletedVersionId?: string | null; driverEvents?: { eventType: string; occurredAt: Date }[]; + driverNavigationUntil?: Date | null; id?: string; routeStops?: { deliveryStop: { status: string } }[]; status?: string; } = {}) { return { + assignmentGeneration: input.assignmentGeneration ?? 1n, constraints: input.constraints ?? { companyDisplayName: 'Tomatono Toronto', timezone: 'America/Toronto' }, + deliveryWorkCompletedAt: input.deliveryWorkCompletedAt ?? null, + deliveryWorkCompletedGeneration: input.deliveryWorkCompletedGeneration ?? null, + deliveryWorkCompletedVersionId: input.deliveryWorkCompletedVersionId ?? null, driverEvents: input.driverEvents ?? [{ eventType: 'ROUTE_COMPLETED', occurredAt: new Date('2026-05-19T08:30:00.000Z') }], + driverNavigationUntil: input.driverNavigationUntil ?? null, id: input.id ?? routePlanId, name: 'Tuesday AM Route', planDate: new Date('2026-05-19T00:00:00.000Z'), diff --git a/apps/delivery-api/tests/driver-token-access.repository.test.ts b/apps/delivery-api/tests/driver-token-access.repository.test.ts index d7b1c4fa..80eb79de 100644 --- a/apps/delivery-api/tests/driver-token-access.repository.test.ts +++ b/apps/delivery-api/tests/driver-token-access.repository.test.ts @@ -60,10 +60,15 @@ describe('PrismaDriverTokenAccessRepository', () => { expect(prisma.routePlan.findFirst).toHaveBeenCalledWith({ select: { + assignmentGeneration: true, + deliveryWorkCompletedAt: true, + deliveryWorkCompletedGeneration: true, + deliveryWorkCompletedVersionId: true, driver: { select: { account: { select: { isStoreReviewAccount: true } }, accountId: true, authSubject: true, id: true, isStoreReviewData: true, status: true } }, id: true, + driverNavigationUntil: true, isStoreReviewData: true, routeGroupingChildVersions: { orderBy: { updatedAt: 'desc' }, @@ -71,7 +76,7 @@ describe('PrismaDriverTokenAccessRepository', () => { take: 1, where: { status: 'CURRENT', supersededAt: null } }, - shop: { select: { id: true, shopDomain: true } }, + shop: { select: { appId: true, id: true, shopDomain: true } }, status: true, }, where: { @@ -122,6 +127,52 @@ describe('PrismaDriverTokenAccessRepository', () => { })).resolves.toBeNull(); }); + test('keeps token access through navigation grace and rejects it at the exact expiry boundary', async () => { + const routePlan = { + assignmentGeneration: 4n, + deliveryWorkCompletedAt: new Date('2026-10-01T16:00:00.000Z'), + deliveryWorkCompletedGeneration: 4n, + deliveryWorkCompletedVersionId: '22222222-2222-4222-8222-222222222222', + driverNavigationUntil: new Date('2026-10-01T18:00:00.000Z'), + driver: { + accountId: 'account-id', authSubject: 'driver-driver-id', id: 'driver-id', status: 'ACTIVE' as const + }, + id: 'route-plan-id', + routeGroupingChildVersions: [{ publishedAt: new Date('2026-10-01T15:00:00.000Z') }], + shop: { appId: 'clever-route-kfood', id: 'shop-id', shopDomain: '7hrud1-xq.myshopify.com' }, + status: 'IN_PROGRESS' + }; + const before = new PrismaDriverTokenAccessRepository(createPrismaHarness({ + account: { status: 'ACTIVE', tokenVersion: 2 }, routePlan + }).prisma as never, () => new Date('2026-10-01T17:59:59.999Z')); + const atBoundary = new PrismaDriverTokenAccessRepository(createPrismaHarness({ + account: { status: 'ACTIVE', tokenVersion: 2 }, routePlan + }).prisma as never, () => new Date('2026-10-01T18:00:00.000Z')); + const foreignTenant = new PrismaDriverTokenAccessRepository(createPrismaHarness({ + account: { status: 'ACTIVE', tokenVersion: 2 }, + routePlan: { ...routePlan, shop: { ...routePlan.shop, appId: 'clever-route-dsv' } } + }).prisma as never, () => new Date('2026-10-01T18:00:00.000Z')); + + await expect(before.resolveDriverRouteAccess({ + accountId: 'account-id', routePlanId: 'route-plan-id', tokenVersion: 2 + })).resolves.toMatchObject({ routePlanId: 'route-plan-id' }); + await expect(atBoundary.resolveDriverRouteAccess({ + accountId: 'account-id', routePlanId: 'route-plan-id', tokenVersion: 2 + })).resolves.toBeNull(); + await expect(atBoundary.resolveDriverRouteAccess({ + accountId: 'account-id', routePlanId: 'route-plan-id', tokenVersion: 2 + }, { allowCompleted: true })).resolves.toBeNull(); + const finalized = new PrismaDriverTokenAccessRepository(createPrismaHarness({ + account: { status: 'ACTIVE', tokenVersion: 2 }, routePlan: { ...routePlan, status: 'COMPLETED' } + }).prisma as never, () => new Date('2026-10-01T18:00:00.000Z')); + await expect(finalized.resolveDriverRouteAccess({ + accountId: 'account-id', routePlanId: 'route-plan-id', tokenVersion: 2 + }, { allowCompleted: true })).resolves.toBeNull(); + await expect(foreignTenant.resolveDriverRouteAccess({ + accountId: 'account-id', routePlanId: 'route-plan-id', tokenVersion: 2 + })).resolves.toMatchObject({ routePlanId: 'route-plan-id' }); + }); + test('resolves the same completed assignment only for completion retry authentication', async () => { const routePlan = { driver: { @@ -263,6 +314,10 @@ function createPrismaHarness(input: { account?: { status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED'; tokenVersion: number } | null; driver?: { tokenVersion: number } | null; routePlan?: { + assignmentGeneration?: bigint; + deliveryWorkCompletedAt?: Date | null; + deliveryWorkCompletedGeneration?: bigint | null; + deliveryWorkCompletedVersionId?: string | null; driver: { account?: { isStoreReviewAccount: boolean }; accountId: string | null; @@ -272,9 +327,10 @@ function createPrismaHarness(input: { status: 'ACTIVE' | 'INACTIVE' | 'SUSPENDED'; } | null; id: string; + driverNavigationUntil?: Date | null; isStoreReviewData?: boolean; routeGroupingChildVersions?: Array<{ publishedAt: Date | null }>; - shop: { id: string; shopDomain: string }; + shop: { appId?: string; id: string; shopDomain: string }; status?: string; } | null; tokenVersion?: number; diff --git a/apps/delivery-api/tests/dsv-g007-prisma-history.test.ts b/apps/delivery-api/tests/dsv-g007-prisma-history.test.ts index a5f90915..436c8626 100644 --- a/apps/delivery-api/tests/dsv-g007-prisma-history.test.ts +++ b/apps/delivery-api/tests/dsv-g007-prisma-history.test.ts @@ -163,7 +163,8 @@ describe('G007 DSV Prisma migration history', () => { test('orders compatibility bridges around the broken mapped-table migrations', async () => { const migrations = await readMigrationNames(); - expect(migrations).toHaveLength(111); + expect(migrations).toHaveLength(112); + expect(migrations).toContain('20261002140000_kfood_delivery_navigation_grace'); expect(migrations).toContain('20261002120000_driver_runtime_diagnostics'); expect(migrations).toContain('20260929120000_add_incomplete_route_plan_status'); expect(migrations).toContain('20260910170000_link_driver_proof_media_delivery_stops'); @@ -416,7 +417,7 @@ describe('G007 DSV Prisma migration history', () => { expect(migrations.indexOf('20260922155500_add_route_tracking_road_match_jobs')).toBeLessThan( migrations.indexOf('20260929120000_add_incomplete_route_plan_status') ); - expect(migrations.at(-1)).toBe('20261002120000_driver_runtime_diagnostics'); + expect(migrations.at(-1)).toBe('20261002140000_kfood_delivery_navigation_grace'); }); test('keeps completion rollout gate outcomes after tenant graph deletion', async () => { diff --git a/apps/delivery-api/tests/kfood-delivery-completion.integration.test.ts b/apps/delivery-api/tests/kfood-delivery-completion.integration.test.ts new file mode 100644 index 00000000..860ab1f5 --- /dev/null +++ b/apps/delivery-api/tests/kfood-delivery-completion.integration.test.ts @@ -0,0 +1,173 @@ +import { randomUUID } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; +import { afterAll, describe, expect, test } from 'vitest'; +import { PrismaDriverEventRepository } from '../src/modules/driver/driver-event.repository.js'; +import { PrismaDriverRouteAccessRepository } from '../src/modules/driver/driver-route-access.repository.js'; +import { PrismaDriverTokenAccessRepository } from '../src/modules/driver/driver-token-access.repository.js'; +import { PrismaRoutePlanRepository } from '../src/modules/route-plans/route-plan.repository.js'; +import { PrismaStaleRouteFinalizationService } from '../src/modules/route-plans/stale-route-finalization.service.js'; +import { replaceCurrentRouteGroupingChildVersion } from '../src/modules/route-grouping/route-grouping.service.js'; +import { PrismaOrderSyncRepository } from '../src/modules/shopify/order-sync.repository.js'; +import { KFOOD_DELIVERY_APP_ID, KFOOD_DELIVERY_SHOP_DOMAIN, reconcileKfoodDeliveryWorkCompletion } from '../src/modules/route-plans/kfood-delivery-completion.js'; + +const url = process.env.KFOOD_COMPLETION_DATABASE_URL; +const enabled = process.env.KFOOD_COMPLETION_DATABASE_TARGET_CLASS === 'safe-local-kfood-completion-disposable'; +if (enabled && url !== undefined) { + const parsed = new URL(url); + if (parsed.protocol !== 'postgresql:' || !['127.0.0.1', 'localhost', '[::1]'].includes(parsed.hostname) + || !parsed.pathname.endsWith('_disposable')) throw new Error('K-food completion tests require a named loopback disposable database'); +} + +(enabled && url !== undefined ? describe : describe.skip)('K-food delivery completion PostgreSQL contract', () => { + const prisma = new PrismaClient({ datasourceUrl: url ?? 'postgresql://disabled@127.0.0.1:1/disabled' }); + afterAll(async () => { await prisma.$disconnect(); }); + + test('last delivery commits admin Complete and allows unchanged mobile access for exactly two hours', async () => { + const f = await fixture(); + let now = new Date(); + const repository = new PrismaDriverEventRepository(prisma, { now: () => now }); + const event = { + driverId: f.driver.id, routePlanId: f.route.id, shopId: f.shop.id, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN, + clientEventId: randomUUID(), deliveryStopId: f.stops[1]!.id, eventType: 'STOP_DELIVERED', + driverContractVersion: 2, expectedRouteVersionId: f.version.id, assignmentGeneration: '2', + occurredAt: now, latitude: null, longitude: null, payload: { source: 'driver-app' } + }; + const receipts = await Promise.all([repository.recordDriverEvent(event), repository.recordDriverEvent(event)]); + expect(receipts.map(receipt => receipt.duplicate).sort()).toEqual([false, true]); + expect(new Set(receipts.map(receipt => receipt.eventId)).size).toBe(1); + const recorded = await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } }); + expect(recorded.status).toBe('IN_PROGRESS'); + expect(recorded.deliveryWorkCompletedAt).not.toBeNull(); + expect(recorded.driverNavigationUntil!.getTime() - recorded.deliveryWorkCompletedAt!.getTime()).toBe(7_200_000); + expect(await prisma.driverEvent.count({ where: { routePlanId: f.route.id, eventType: 'ROUTE_COMPLETED' } })).toBe(0); + const admin = new PrismaRoutePlanRepository(prisma); + const summaries = await admin.listRoutePlans({ appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN }); + expect(summaries.find(r => r.id === f.route.id)?.status).toBe('COMPLETED'); + + const access = new PrismaDriverRouteAccessRepository(prisma, undefined, () => now); + const tokens = new PrismaDriverTokenAccessRepository(prisma, () => now); + const identity = { accountId: f.account.id, routePlanId: f.route.id, tokenVersion: f.account.tokenVersion }; + now = new Date(recorded.driverNavigationUntil!.getTime() - 1); + expect(await tokens.resolveDriverRouteAccess(identity)).not.toBeNull(); + expect((await access.lookupRouteAccess({ accountId: f.account.id, routeContext: f.route.id })).status).toBe('INVITED'); + + // A queued replay preserves the original deadline even when received later. + await expect(repository.recordDriverEvent(event)).resolves.toMatchObject({ duplicate: true }); + const replayed = await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } }); + expect(replayed.driverNavigationUntil).toEqual(recorded.driverNavigationUntil); + const completionCommand = { ...event, clientEventId: randomUUID(), eventType: 'ROUTE_COMPLETED', deliveryStopId: null }; + const completionAck = await repository.recordDriverEvent(completionCommand); + expect(completionAck.duplicate).toBe(false); + expect(await prisma.driverEvent.findUnique({ where: { id: completionAck.eventId } })).toMatchObject({ + eventType: 'NOTE_ADDED', payload: { schema: 'kfood_return_navigation_completion_ack_v1' } + }); + await expect(repository.recordDriverEvent(completionCommand)).resolves.toMatchObject({ duplicate: true, eventId: completionAck.eventId }); + expect((await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } })).status).toBe('IN_PROGRESS'); + expect(await tokens.resolveDriverRouteAccess(identity)).not.toBeNull(); + await expect(repository.recordDriverEvent({ ...event, clientEventId: randomUUID(), eventType: 'ROUTE_PAUSED', deliveryStopId: null })) + .rejects.toThrow('only return navigation'); + + now = recorded.driverNavigationUntil!; + await expect(repository.recordDriverEvent(event)).resolves.toMatchObject({ duplicate: true }); + expect(await tokens.resolveDriverRouteAccess(identity)).toBeNull(); + expect(await tokens.resolveDriverRouteAccess(identity, { allowCompleted: true })).toBeNull(); + expect(await access.lookupRouteAccess({ accountId: f.account.id, routeContext: f.route.id })).toEqual({ status: 'NOT_FOUND' }); + expect(await access.lookupRouteAccess({ accountId: f.account.id, routeContext: null })).toEqual({ status: 'ROUTES_FOUND', routes: [] }); + await new PrismaStaleRouteFinalizationService(prisma).processDue(now); + expect((await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } })).status).toBe('COMPLETED'); + expect(await tokens.resolveDriverRouteAccess(identity, { allowCompleted: true })).toBeNull(); + expect(await prisma.driverEvent.count({ where: { routePlanId: f.route.id, eventType: 'ROUTE_COMPLETED' } })).toBe(0); + }); + + test('ARRIVED remains unresolved and a corrected stop clears an existing completion marker', async () => { + const f = await fixture(); + const reconcile = async () => prisma.$transaction(async tx => { + await tx.$queryRaw`SELECT id FROM route_plans WHERE id = ${f.route.id}::uuid FOR UPDATE`; + return reconcileKfoodDeliveryWorkCompletion(tx, { routePlanId: f.route.id, shopId: f.shop.id, now: new Date() }); + }); + expect(await reconcile()).toBeNull(); + const admin = new PrismaRoutePlanRepository(prisma); + const transition = (status: 'COMPLETED' | 'READY') => admin.transitionAdminRouteStop({ + appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN, + actor: 'navigation-grace-test', routePlanId: f.route.id, deliveryStopId: f.stops[1]!.id, + payload: { status, idempotencyKey: randomUUID() } + }); + await transition('COMPLETED'); + expect((await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } })).deliveryWorkCompletedAt).not.toBeNull(); + await transition('READY'); + const reopened = await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } }); + expect(reopened.status).toBe('IN_PROGRESS'); + expect(reopened.deliveryWorkCompletedAt).toBeNull(); + expect(reopened.driverNavigationUntil).toBeNull(); + expect((await prisma.deliveryStop.findUniqueOrThrow({ where: { id: f.stops[1]!.id } })).status).toBe('PENDING'); + }); + + test('Shopify bulk completion and correction reconcile the current route', async () => { + const f = await fixture(); + const orders = new PrismaOrderSyncRepository(prisma); + const patch = (value: 'DELIVERED' | 'ARRIVED') => orders.bulkPatchCanonicalOrderStatus({ + actor: 'navigation-grace-test', appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN, + field: 'state', orderIds: [f.stops[1]!.orderId], value + }); + await patch('DELIVERED'); + expect((await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } })).deliveryWorkCompletedAt).not.toBeNull(); + await patch('ARRIVED'); + expect(await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } })).toMatchObject({ + status: 'IN_PROGRESS', deliveryWorkCompletedAt: null, driverNavigationUntil: null + }); + }); + + test('same-driver child replacement invalidates completion without changing assignment generation', async () => { + const f = await fixture(); + await new PrismaOrderSyncRepository(prisma).bulkPatchCanonicalOrderStatus({ + actor: 'navigation-grace-test', appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN, + field: 'state', orderIds: [f.stops[1]!.orderId], value: 'DELIVERED' + }); + expect((await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } })).deliveryWorkCompletedAt).not.toBeNull(); + await prisma.$transaction(async tx => { + await tx.$queryRaw`SELECT id FROM route_plans WHERE id = ${f.route.id}::uuid FOR UPDATE`; + await replaceCurrentRouteGroupingChildVersion(tx, { + currentChildId: f.version.id, driverId: f.driver.id, groupingId: f.group.id, + groupingVersionId: f.parent.id, notificationStatus: 'SKIPPED', + orderIds: f.stops.map(stop => stop.orderId), publishedAt: new Date(), routePlanId: f.route.id, + shopId: f.shop.id, snapshot: { membershipSchemaVersion: 1, stops: f.members }, version: 2 + }); + }); + expect(await prisma.routePlan.findUniqueOrThrow({ where: { id: f.route.id } })).toMatchObject({ + status: 'IN_PROGRESS', assignmentGeneration: 2n, deliveryWorkCompletedAt: null, + driverNavigationUntil: null, deliveryWorkCompletedVersionId: null + }); + const admin = await new PrismaRoutePlanRepository(prisma).listRoutePlans({ + appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN + }); + expect(admin.find(route => route.id === f.route.id)?.status).toBe('IN_PROGRESS'); + }); + + async function fixture() { + const suffix = randomUUID(); + const shop = await prisma.shop.upsert({ + where: { appId_shopDomain: { appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN } }, + create: { appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN }, update: {} + }); + const account = await prisma.driverAccount.create({ data: { phone: `grace-${suffix}` } }); + const driver = await prisma.driver.create({ data: { accountId: account.id, authSubject: `grace-${suffix}`, displayName: 'Navigation grace test', shopId: shop.id } }); + const route = await prisma.routePlan.create({ data: { shopId: shop.id, driverId: driver.id, name: `grace-${suffix}`, + planDate: new Date(), constraints: { timezone: 'America/Toronto' }, metrics: {}, optimizerVersion: 'test', status: 'IN_PROGRESS', assignmentGeneration: 2n } }); + const group = await prisma.routeGrouping.create({ data: { shopId: shop.id, name: `grace-${suffix}`, planDate: new Date() } }); + const parent = await prisma.routeGroupingVersion.create({ data: { shopId: shop.id, groupingId: group.id, version: 1 } }); + const version = await prisma.routeGroupingChildVersion.create({ data: { shopId: shop.id, groupingId: group.id, + groupingVersionId: parent.id, routePlanId: route.id, driverId: driver.id, version: 1, snapshot: {}, publishedAt: new Date() } }); + const stops = []; + const members = []; + for (let index = 0; index < 2; index += 1) { + const order = await prisma.order.create({ data: { shopId: shop.id, name: `#test-${index}`, rawPayload: {}, + shopifyOrderGid: `gid://shopify/Order/${suffix}-${index}`, currentRouteVersionId: version.id } }); + const stop = await prisma.deliveryStop.create({ data: { shopId: shop.id, orderId: order.id, status: index === 0 ? 'DELIVERED' : 'ARRIVED' } }); + await prisma.routePlanStop.create({ data: { shopId: shop.id, routePlanId: route.id, deliveryStopId: stop.id, sequence: index + 1 } }); + stops.push(stop); + members.push({ sequence: index + 1, deliveryStopId: stop.id, orderId: order.id }); + } + await prisma.routeGroupingChildVersion.update({ where: { id: version.id }, data: { snapshot: { membershipSchemaVersion: 1, stops: members } } }); + return { shop, account, driver, route, version, stops, group, parent, members }; + } +}); diff --git a/apps/delivery-api/tests/kfood-delivery-completion.test.ts b/apps/delivery-api/tests/kfood-delivery-completion.test.ts new file mode 100644 index 00000000..22fbf3aa --- /dev/null +++ b/apps/delivery-api/tests/kfood-delivery-completion.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, test, vi } from 'vitest'; +import { + hasDeliveryNavigationGraceExpired, hasDeliveryWorkCompleted, KFOOD_DELIVERY_APP_ID, + KFOOD_DELIVERY_SHOP_DOMAIN, KFOOD_RETURN_NAVIGATION_GRACE_MS, + reconcileKfoodDeliveryWorkCompletion, toRouteDeliveryDisplayStatus +} from '../src/modules/route-plans/kfood-delivery-completion.js'; + +const completedAt = new Date('2026-10-01T17:30:00Z'); +const navigationUntil = new Date(completedAt.getTime() + KFOOD_RETURN_NAVIGATION_GRACE_MS); +const marker = { + assignmentGeneration: 2n, deliveryWorkCompletedGeneration: 2n, + deliveryWorkCompletedVersionId: 'current-version', deliveryWorkCompletedAt: completedAt, + driverNavigationUntil: navigationUntil, status: 'IN_PROGRESS' +}; + +describe('K-food delivery work completion with return navigation', () => { + test('shows Complete immediately while preserving the raw mobile execution state', () => { + expect(toRouteDeliveryDisplayStatus(marker)).toBe('COMPLETED'); + expect(marker.status).toBe('IN_PROGRESS'); + expect(hasDeliveryNavigationGraceExpired(marker, new Date(navigationUntil.getTime() - 1))).toBe(false); + expect(hasDeliveryNavigationGraceExpired(marker, navigationUntil)).toBe(true); + }); + + test('does not reuse a previous assignment or a malformed interval', () => { + expect(toRouteDeliveryDisplayStatus({ ...marker, assignmentGeneration: 3n })).toBe('IN_PROGRESS'); + expect(hasDeliveryNavigationGraceExpired({ ...marker, assignmentGeneration: 3n }, navigationUntil)).toBe(false); + expect(hasDeliveryWorkCompleted({ ...marker, driverNavigationUntil: completedAt })).toBe(false); + expect(hasDeliveryWorkCompleted({ ...marker, deliveryWorkCompletedVersionId: null })).toBe(false); + expect(toRouteDeliveryDisplayStatus({ ...marker, status: 'INCOMPLETE' })).toBe('INCOMPLETE'); + expect(toRouteDeliveryDisplayStatus({ status: 'IN_PROGRESS' })).toBe('IN_PROGRESS'); + }); + + test('starts a server-time grace only after every current snapshot stop is resolved', async () => { + const harness = createHarness(); + const result = await reconcileKfoodDeliveryWorkCompletion(harness.tx as never, input()); + expect(result).toEqual({ completedAt, navigationUntil, assignmentGeneration: 2n, routeVersionId: 'current-version' }); + expect(harness.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { + id: 'route', shopId: 'shop', status: 'IN_PROGRESS', + shop: { appId: KFOOD_DELIVERY_APP_ID, shopDomain: KFOOD_DELIVERY_SHOP_DOMAIN } + } })); + expect(harness.updateMany).toHaveBeenCalledWith({ + where: { id: 'route', shopId: 'shop', status: 'IN_PROGRESS', assignmentGeneration: 2n }, + data: { deliveryWorkCompletedAt: completedAt, driverNavigationUntil: navigationUntil, + deliveryWorkCompletedGeneration: 2n, deliveryWorkCompletedVersionId: 'current-version' } + }); + }); + + test.each(['PENDING', 'ASSIGNED', 'EN_ROUTE', 'ARRIVED'])('keeps %s unresolved rather than inventing completion', async (status) => { + const harness = createHarness(); + harness.route.routeStops[1]!.deliveryStop.status = status; + expect(await reconcileKfoodDeliveryWorkCompletion(harness.tx as never, input())).toBeNull(); + expect(harness.updateMany).not.toHaveBeenCalled(); + }); + + test('does not close empty, truncated, duplicated, or mismatched current membership', async () => { + for (const corrupt of [ + (route: ReturnType['route']) => { route.routeStops = []; }, + (route: ReturnType['route']) => { route.routeGroupingChildVersions[0]!.snapshot.stops.pop(); }, + (route: ReturnType['route']) => { route.routeStops[1]!.deliveryStop.order.currentRouteVersionId = 'different-version'; }, + (route: ReturnType['route']) => { route.routeGroupingChildVersions[0]!.snapshot.stops[1]!.deliveryStopId = 'stop-1'; }, + (route: ReturnType['route']) => { route.routeGroupingChildVersions.push(route.routeGroupingChildVersions[0]!); } + ]) { + const harness = createHarness(); + corrupt(harness.route); + expect(await reconcileKfoodDeliveryWorkCompletion(harness.tx as never, input())).toBeNull(); + expect(harness.updateMany).not.toHaveBeenCalled(); + } + }); + + test('a retry, return intent, or GPS refresh cannot extend an existing valid grace', async () => { + const harness = createHarness(marker); + expect(await reconcileKfoodDeliveryWorkCompletion(harness.tx as never, { + ...input(), now: new Date('2026-10-01T19:00:00Z') + })).toEqual({ completedAt, navigationUntil, assignmentGeneration: 2n, routeVersionId: 'current-version' }); + expect(harness.updateMany).not.toHaveBeenCalled(); + }); + + test('a corrected unresolved stop clears the marker and reopens delivery work', async () => { + const harness = createHarness(marker); + harness.route.routeStops[1]!.deliveryStop.status = 'ARRIVED'; + expect(await reconcileKfoodDeliveryWorkCompletion(harness.tx as never, input())).toBeNull(); + expect(harness.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: { + deliveryWorkCompletedAt: null, driverNavigationUntil: null, + deliveryWorkCompletedGeneration: null, deliveryWorkCompletedVersionId: null + } })); + }); + + test('the periodic finalizer never starts a grace for a historical route', async () => { + const harness = createHarness(); + expect(await reconcileKfoodDeliveryWorkCompletion(harness.tx as never, { ...input(), allowStart: false })).toBeNull(); + expect(harness.updateMany).not.toHaveBeenCalled(); + }); + + test('a lost ownership CAS does not report successful completion', async () => { + const harness = createHarness(); + harness.updateMany.mockResolvedValue({ count: 0 }); + expect(await reconcileKfoodDeliveryWorkCompletion(harness.tx as never, input())).toBeNull(); + }); + + test('other tenants, terminal routes, and unavailable rows are left untouched', async () => { + const harness = createHarness(); + harness.findFirst.mockResolvedValue(null); + expect(await reconcileKfoodDeliveryWorkCompletion(harness.tx as never, input())).toBeNull(); + expect(harness.updateMany).not.toHaveBeenCalled(); + }); +}); + +function input() { return { routePlanId: 'route', shopId: 'shop', now: completedAt }; } +function createHarness(overrides: Partial = {}) { + const route = { + id: 'route', status: 'IN_PROGRESS', assignmentGeneration: 2n, + deliveryWorkCompletedAt: null as Date | null, driverNavigationUntil: null as Date | null, + deliveryWorkCompletedGeneration: null as bigint | null, deliveryWorkCompletedVersionId: null as string | null, + routeGroupingChildVersions: [{ id: 'current-version', snapshot: { membershipSchemaVersion: 1, + stops: [{ sequence: 1, deliveryStopId: 'stop-1', orderId: 'order-1' }, + { sequence: 2, deliveryStopId: 'stop-2', orderId: 'order-2' }] } }], + routeStops: [1, 2].map(sequence => ({ sequence, deliveryStopId: `stop-${sequence}`, deliveryStop: { + status: 'DELIVERED', orderId: `order-${sequence}`, order: { currentRouteVersionId: 'current-version' } + } })), ...overrides + }; + const findFirst = vi.fn().mockResolvedValue(route); + const updateMany = vi.fn().mockResolvedValue({ count: 1 }); + return { route, findFirst, updateMany, tx: { routePlan: { findFirst, updateMany } } }; +} diff --git a/apps/delivery-api/tests/order-sync.repository.test.ts b/apps/delivery-api/tests/order-sync.repository.test.ts index a243d49e..4a168462 100644 --- a/apps/delivery-api/tests/order-sync.repository.test.ts +++ b/apps/delivery-api/tests/order-sync.repository.test.ts @@ -350,6 +350,95 @@ describe('PrismaOrderSyncRepository canonical orders', () => { expect(prisma.order.update).toHaveBeenCalledWith(paymentUpdateMatcher); }); + test('bulk delivery correction locks the affected route and starts a valid completion marker', async () => { + const { prisma } = createPrismaHarness({ existingOrder: null, routeStopCount: 1 }); + prisma.order.findMany + .mockResolvedValueOnce([orderStatusPatchRecord()]) + .mockResolvedValueOnce([orderStatusPatchRecord()]) + .mockResolvedValueOnce([orderStatusPatchRecord()]) + .mockResolvedValueOnce([canonicalOrderRecord(1)]); + prisma.routePlan.findFirst.mockResolvedValue(orderCompletionReconciliationRoute('DELIVERED')); + const repository = createOrderSyncRepository(prisma); + + await repository.bulkPatchCanonicalOrderStatus({ + actor: 'shopify-user-id', + field: 'state', + orderIds: ['order-id'], + shopDomain: 'example.myshopify.com', + value: 'DELIVERED' + }); + + expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); + const lockCalls = prisma.$queryRaw.mock.calls as unknown as Array<[TemplateStringsArray, string, string]>; + expect(lockCalls[0]?.[1]).toBe('route-plan-id'); + expect(lockCalls[1]?.[1]).toBe('order-id'); + const completionUpdate = prisma.routePlan.updateMany.mock.calls.at(-1)?.[0] as unknown as { + data: { + deliveryWorkCompletedAt: Date; + deliveryWorkCompletedGeneration: bigint; + deliveryWorkCompletedVersionId: string; + driverNavigationUntil: Date; + }; + where: { id: string; shopId: string; status: string }; + } | undefined; + expect(completionUpdate?.data.deliveryWorkCompletedAt).toBeInstanceOf(Date); + expect(completionUpdate?.data.deliveryWorkCompletedGeneration).toBe(1n); + expect(completionUpdate?.data.deliveryWorkCompletedVersionId).toBe('route-version-id'); + expect(completionUpdate?.data.driverNavigationUntil).toBeInstanceOf(Date); + expect(completionUpdate?.where).toMatchObject({ id: 'route-plan-id', shopId: 'shop-id', status: 'IN_PROGRESS' }); + }); + + test('bulk reopen correction clears a previously valid completion marker under the route lock', async () => { + const { prisma } = createPrismaHarness({ existingOrder: null, routeStopCount: 1 }); + prisma.order.findMany + .mockResolvedValueOnce([orderStatusPatchRecord()]) + .mockResolvedValueOnce([orderStatusPatchRecord()]) + .mockResolvedValueOnce([orderStatusPatchRecord()]) + .mockResolvedValueOnce([canonicalOrderRecord(1)]); + prisma.routePlan.findFirst.mockResolvedValue(orderCompletionReconciliationRoute('PENDING', true)); + const repository = createOrderSyncRepository(prisma); + + await repository.bulkPatchCanonicalOrderStatus({ + actor: 'shopify-user-id', + field: 'state', + orderIds: ['order-id'], + shopDomain: 'example.myshopify.com', + value: 'PENDING' + }); + + expect(prisma.$queryRaw).toHaveBeenCalledTimes(2); + expect(prisma.routePlan.updateMany).toHaveBeenCalledWith({ + data: { + deliveryWorkCompletedAt: null, + deliveryWorkCompletedGeneration: null, + deliveryWorkCompletedVersionId: null, + driverNavigationUntil: null + }, + where: { assignmentGeneration: 1n, id: 'route-plan-id', shopId: 'shop-id', status: 'IN_PROGRESS' } + }); + }); + + test('bulk state correction aborts before writes when route membership changes during lock acquisition', async () => { + const { prisma } = createPrismaHarness({ existingOrder: null, routeStopCount: 1 }); + prisma.order.findMany + .mockResolvedValueOnce([orderStatusPatchRecord()]) + .mockResolvedValueOnce([orderStatusPatchRecord()]) + .mockResolvedValueOnce([orderStatusPatchRecord('other-route-plan-id')]); + const repository = createOrderSyncRepository(prisma); + + await expect(repository.bulkPatchCanonicalOrderStatus({ + actor: 'shopify-user-id', + field: 'state', + orderIds: ['order-id'], + shopDomain: 'example.myshopify.com', + value: 'DELIVERED' + })).rejects.toBeInstanceOf(OrderSyncRouteLockedError); + + expect(prisma.order.update).not.toHaveBeenCalled(); + expect(prisma.deliveryStop.upsert).not.toHaveBeenCalled(); + expect(prisma.routePlan.updateMany).not.toHaveBeenCalled(); + }); + test('keeps manual payment override when Shopify sync refreshes the order', async () => { const { prisma } = createPrismaHarness({ existingOrder: { @@ -2353,6 +2442,7 @@ function createPrismaHarness(input: { upsert: ReturnType; }; orderDeliveryFact: { findMany: ReturnType; upsert: ReturnType }; + routePlan: { findFirst: ReturnType; updateMany: ReturnType }; shopifyOrderRedactionTombstone: { findUnique: ReturnType }; shopifyShopRedactionTombstone: { findUnique: ReturnType }; shop: { @@ -2402,6 +2492,10 @@ function createPrismaHarness(input: { findMany: vi.fn(() => Promise.resolve([])), upsert: vi.fn(() => Promise.resolve({ id: 'fact-id' })) }, + routePlan: { + findFirst: vi.fn(() => Promise.resolve(null)), + updateMany: vi.fn(() => Promise.resolve({ count: 1 })) + }, shopifyOrderRedactionTombstone: { findUnique: vi.fn(() => Promise.resolve(input.tombstonedOrder === true ? { id: 'tombstone-id' } : null)) }, @@ -2454,6 +2548,39 @@ function routedExistingOrder( }; } +function orderStatusPatchRecord(routePlanId = 'route-plan-id'): Record { + return { + deliveryStops: [{ routePlanStops: [{ routePlanId }] }], + id: 'order-id', + rawPayload: {} + }; +} + +function orderCompletionReconciliationRoute(stopStatus: string, completed = false): Record { + const completedAt = completed ? new Date('2026-10-01T22:00:00.000Z') : null; + return { + assignmentGeneration: 1n, + deliveryWorkCompletedAt: completedAt, + deliveryWorkCompletedGeneration: completed ? 1n : null, + deliveryWorkCompletedVersionId: completed ? 'route-version-id' : null, + driverNavigationUntil: completed ? new Date('2026-10-02T00:00:00.000Z') : null, + id: 'route-plan-id', + routeGroupingChildVersions: [{ + id: 'route-version-id', + snapshot: { + membershipSchemaVersion: 1, + stops: [{ deliveryStopId: 'stop-id', orderId: 'order-id', sequence: 1 }] + } + }], + routeStops: [{ + deliveryStop: { order: { currentRouteVersionId: 'route-version-id' }, orderId: 'order-id', status: stopStatus }, + deliveryStopId: 'stop-id', + sequence: 1 + }], + status: 'IN_PROGRESS' + }; +} + function syncedOrder(overrides: Partial = {}): SyncedOrderWithDeliveryStopInput { return { deliveryStop: { diff --git a/apps/delivery-api/tests/route-grouping.service.test.ts b/apps/delivery-api/tests/route-grouping.service.test.ts index 601e566b..32f2eabb 100644 --- a/apps/delivery-api/tests/route-grouping.service.test.ts +++ b/apps/delivery-api/tests/route-grouping.service.test.ts @@ -45,6 +45,28 @@ describe('route grouping contracts', () => { expect(deriveGroupingDisplayStatus(group(['COMPLETED', 'READY']))).toBe('IN_PROGRESS'); }); + test('projects a current child complete from a matching delivery-work marker during navigation grace', () => { + const completedAt = new Date('2026-10-01T22:00:00.000Z'); + const group = { + childVersions: [{ + routePlan: { + assignmentGeneration: 4n, + deliveryWorkCompletedAt: completedAt, + deliveryWorkCompletedGeneration: 4n, + deliveryWorkCompletedVersionId: 'route-version-id', + driverEvents: [{ eventType: 'ROUTE_STARTED' }], + driverNavigationUntil: new Date('2026-10-02T00:00:00.000Z'), + status: 'IN_PROGRESS' + }, + status: 'CURRENT', + supersededAt: null + }], + status: 'READY' + }; + + expect(deriveGroupingDisplayStatus(group)).toBe('COMPLETED'); + }); + test('requires a scheduled departure to fall on the route plan date in its local timezone', () => { const route = { branchId: null, @@ -1161,6 +1183,13 @@ describe('route grouping contracts', () => { const nextSnapshot = { stops: [{ orderId: 'order-old' }, { orderId: 'order-new' }] }; const prisma = { order: { updateMany: vi.fn(() => { calls.push('rebind'); return Promise.resolve({ count: 2 }); }) }, + routePlan: { + updateMany: vi.fn((...args: [unknown]) => { + void args; + calls.push('clear-completion'); + return Promise.resolve({ count: 1 }); + }) + }, routeGroupingChildVersion: { create: vi.fn((...args: [unknown]) => { void args; calls.push('create'); return Promise.resolve({ id: 'child-next' }); }), updateMany: vi.fn((...args: [unknown]) => { void args; calls.push('archive'); return Promise.resolve({ count: 1 }); }) @@ -1173,7 +1202,7 @@ describe('route grouping contracts', () => { routePlanId: 'route-id', shopId: 'shop-id', snapshot: nextSnapshot, version: 7 })).resolves.toBe('child-next'); - expect(calls).toEqual(['archive', 'create', 'rebind']); + expect(calls).toEqual(['archive', 'create', 'clear-completion', 'rebind']); expect(oldSnapshot).toEqual({ stops: [{ orderId: 'order-old' }] }); const archiveCall: unknown = prisma.routeGroupingChildVersion.updateMany.mock.calls[0]?.[0]; const createCall: unknown = prisma.routeGroupingChildVersion.create.mock.calls[0]?.[0]; @@ -1182,6 +1211,19 @@ describe('route grouping contracts', () => { where: { id: 'child-old', status: 'CURRENT', supersededAt: null } }); expect(createCall).toMatchObject({ data: { snapshot: nextSnapshot, status: 'CURRENT', supersededAt: null } }); + expect(prisma.routePlan.updateMany).toHaveBeenCalledWith({ + data: { + deliveryWorkCompletedAt: null, + deliveryWorkCompletedGeneration: null, + deliveryWorkCompletedVersionId: null, + driverNavigationUntil: null + }, + where: { + deliveryWorkCompletedVersionId: 'child-old', + id: 'route-id', + shopId: 'shop-id' + } + }); }); test('allows draft saves to persist a validated vehicle on child route plans', () => { diff --git a/apps/delivery-api/tests/route-operational-state.service.test.ts b/apps/delivery-api/tests/route-operational-state.service.test.ts index 454fd780..94c0e538 100644 --- a/apps/delivery-api/tests/route-operational-state.service.test.ts +++ b/apps/delivery-api/tests/route-operational-state.service.test.ts @@ -61,7 +61,7 @@ describe('route operational state', () => { test('anchors the position window to the first route-start event for delayed routes', async () => { const route = routeRecord({ - driverEvents: [{ occurredAt: new Date('2026-08-26T12:00:00.000Z') }], + driverEvents: [{ eventType: 'ROUTE_STARTED', occurredAt: new Date('2026-08-26T12:00:00.000Z') }], id: 'delayed-route', planDate: new Date('2026-08-24T00:00:00.000Z'), trackingGeometry: geometry('delayed-event', '2026-08-27T08:00:00.000Z'), @@ -97,6 +97,32 @@ describe('route operational state', () => { expect(state?.syncHealth).toBe(syncHealth); }); + test('reports immediate server completion and navigation timestamps from a valid persisted marker', async () => { + const completedAt = new Date('2026-10-01T22:00:00.000Z'); + const navigationUntil = new Date('2026-10-02T00:00:00.000Z'); + const route = routeRecord({ + assignmentGeneration: 2n, + deliveryWorkCompletedAt: completedAt, + deliveryWorkCompletedGeneration: 2n, + deliveryWorkCompletedVersionId: 'route-version-id', + driverNavigationUntil: navigationUntil, + id: 'completed-during-grace', + status: 'IN_PROGRESS' + }); + const state = await new PrismaRouteOperationalStateService( + { driverEvent: { findMany: vi.fn().mockResolvedValue([]) }, routePlan: { findMany: vi.fn().mockResolvedValue([route]) } } as never, + { getActiveSyncHealthForRoutePlans: vi.fn().mockResolvedValue(new Map()) } as never, + { listActiveForRoutePlans: vi.fn().mockResolvedValue(new Map()) } as never, + () => new Date('2026-10-01T22:30:00.000Z') + ).get('completed-during-grace'); + + expect(state).toMatchObject({ + deliveryWorkCompletedAt: completedAt.toISOString(), + driverNavigationUntil: navigationUntil.toISOString(), + routeStatus: 'COMPLETED' + }); + }); + test('batches Kitchener, unknown evidence, and completed unresolved routes with bounded reads', async () => { const now = new Date('2026-08-24T08:00:00.000Z'); const routePlanFindMany = vi.fn().mockResolvedValue([ @@ -177,7 +203,12 @@ async function createState(input: { accuracyMeters: number; occurredAt?: string; } function routeRecord(input: { - driverEvents?: Array<{ occurredAt: Date }>; + assignmentGeneration?: bigint; + deliveryWorkCompletedAt?: Date | null; + deliveryWorkCompletedGeneration?: bigint | null; + deliveryWorkCompletedVersionId?: string | null; + driverEvents?: Array<{ eventType: string; occurredAt: Date }>; + driverNavigationUntil?: Date | null; driverRouteSessionLeases?: Array<{ syncSession: { heartbeats: Array<{ completedStopCount: number; currentStopSequence: number; locallyFinished: boolean; totalStopCount: number }> } }>; id: string; planDate?: Date; @@ -186,9 +217,14 @@ function routeRecord(input: { trackingGeometry?: ReturnType | null; }) { return { + assignmentGeneration: input.assignmentGeneration ?? 1n, constraints: { operationalHealth: { maxGpsAccuracyMeters: 100, proximityThresholdMeters: 75 }, scheduledStartTimeZone: 'America/Toronto' }, + deliveryWorkCompletedAt: input.deliveryWorkCompletedAt ?? null, + deliveryWorkCompletedGeneration: input.deliveryWorkCompletedGeneration ?? null, + deliveryWorkCompletedVersionId: input.deliveryWorkCompletedVersionId ?? null, driverEvents: input.driverEvents ?? [], driverRouteSessionLeases: input.driverRouteSessionLeases ?? [], + driverNavigationUntil: input.driverNavigationUntil ?? null, id: input.id, planDate: input.planDate ?? new Date('2026-08-24T00:00:00.000Z'), routeStops: input.routeStops ?? [], diff --git a/apps/delivery-api/tests/route-plan.repository.test.ts b/apps/delivery-api/tests/route-plan.repository.test.ts index 53e9ff69..80f615ad 100644 --- a/apps/delivery-api/tests/route-plan.repository.test.ts +++ b/apps/delivery-api/tests/route-plan.repository.test.ts @@ -132,6 +132,52 @@ describe('PrismaRoutePlanRepository', () => { }); }); + test('projects a persisted delivery-work completion marker immediately while execution remains in progress', async () => { + const completedAt = new Date('2026-10-01T22:00:00.000Z'); + const { prisma } = createPrismaHarness({ + routePlanFindFirst: routePlanRecord({ + deliveryWorkCompletedAt: completedAt, + deliveryWorkCompletedGeneration: 3n, + deliveryWorkCompletedVersionId: 'route-version-id', + driverNavigationUntil: new Date('2026-10-02T00:00:00.000Z'), + assignmentGeneration: 3n, + driverEvents: [{ eventType: 'ROUTE_STARTED' }], + status: 'IN_PROGRESS' + }) + }); + const repository = new PrismaRoutePlanRepository(prisma as never); + + const detail = await repository.findRoutePlanDetail({ + routePlanId: 'route-plan-id', + shopDomain: 'example.myshopify.com' + }); + + expect(detail?.routePlan.status).toBe('COMPLETED'); + }); + + test('does not project completion from a marker belonging to another assignment generation', async () => { + const completedAt = new Date('2026-10-01T22:00:00.000Z'); + const { prisma } = createPrismaHarness({ + routePlanFindFirst: routePlanRecord({ + deliveryWorkCompletedAt: completedAt, + deliveryWorkCompletedGeneration: 2n, + deliveryWorkCompletedVersionId: 'route-version-id', + driverNavigationUntil: new Date('2026-10-02T00:00:00.000Z'), + assignmentGeneration: 3n, + driverEvents: [{ eventType: 'ROUTE_STARTED' }], + status: 'IN_PROGRESS' + }) + }); + const repository = new PrismaRoutePlanRepository(prisma as never); + + const detail = await repository.findRoutePlanDetail({ + routePlanId: 'route-plan-id', + shopDomain: 'example.myshopify.com' + }); + + expect(detail?.routePlan.status).toBe('IN_PROGRESS'); + }); + test('projects standalone publication attempts but does not inherit them into an unpublished grouped version', async () => { const publishedAt = new Date('2026-09-11T13:00:00.000Z'); const standaloneHarness = createPrismaHarness({ @@ -1395,6 +1441,77 @@ describe('PrismaRoutePlanRepository', () => { }); }); + test('admin completion reconciles the delivery-work marker while holding the route row lock', async () => { + const { prisma } = createPrismaHarness({ + routePlanStopFindFirst: { + deliveryStop: { completionAssistanceCandidateId: null, order: { email: null }, orderId: 'order-1', status: 'ARRIVED' }, + deliveryStopId: 'stop-1', + routePlan: { status: 'IN_PROGRESS' } + } + }); + prisma.routePlan.findFirst + .mockResolvedValueOnce(completionReconciliationRoute('DELIVERED')) + .mockResolvedValueOnce(routePlanRecord({ status: 'IN_PROGRESS' })); + const repository = new PrismaRoutePlanRepository(prisma as never); + + await repository.transitionAdminRouteStop({ + actor: 'admin-user', + deliveryStopId: 'stop-1', + payload: { idempotencyKey: 'complete-last-stop', status: 'COMPLETED' }, + routePlanId: 'route-plan-id', + shopDomain: 'example.myshopify.com' + }); + + expect(prisma.$queryRaw).toHaveBeenCalledOnce(); + const completionUpdate = prisma.routePlan.updateMany.mock.calls.at(-1)?.[0] as unknown as { + data: { + deliveryWorkCompletedAt: Date; + deliveryWorkCompletedGeneration: bigint; + deliveryWorkCompletedVersionId: string; + driverNavigationUntil: Date; + }; + where: { id: string; shopId: string; status: string }; + } | undefined; + expect(completionUpdate?.data.deliveryWorkCompletedAt).toBeInstanceOf(Date); + expect(completionUpdate?.data.deliveryWorkCompletedGeneration).toBe(1n); + expect(completionUpdate?.data.deliveryWorkCompletedVersionId).toBe('route-version-id'); + expect(completionUpdate?.data.driverNavigationUntil).toBeInstanceOf(Date); + expect(completionUpdate?.where).toMatchObject({ id: 'route-plan-id', shopId: 'shop-id', status: 'IN_PROGRESS' }); + }); + + test('admin reopen clears a previously valid delivery-work marker under the same route lock', async () => { + const { prisma } = createPrismaHarness({ + routePlanStopFindFirst: { + deliveryStop: { completionAssistanceCandidateId: null, order: { email: null }, orderId: 'order-1', status: 'DELIVERED' }, + deliveryStopId: 'stop-1', + routePlan: { status: 'IN_PROGRESS' } + } + }); + prisma.routePlan.findFirst + .mockResolvedValueOnce(completionReconciliationRoute('PENDING', true)) + .mockResolvedValueOnce(routePlanRecord({ status: 'IN_PROGRESS' })); + const repository = new PrismaRoutePlanRepository(prisma as never); + + await repository.transitionAdminRouteStop({ + actor: 'admin-user', + deliveryStopId: 'stop-1', + payload: { idempotencyKey: 'reopen-last-stop', status: 'READY' }, + routePlanId: 'route-plan-id', + shopDomain: 'example.myshopify.com' + }); + + expect(prisma.$queryRaw).toHaveBeenCalledOnce(); + expect(prisma.routePlan.updateMany).toHaveBeenCalledWith({ + data: { + deliveryWorkCompletedAt: null, + deliveryWorkCompletedGeneration: null, + deliveryWorkCompletedVersionId: null, + driverNavigationUntil: null + }, + where: { assignmentGeneration: 1n, id: 'route-plan-id', shopId: 'shop-id', status: 'IN_PROGRESS' } + }); + }); + test('does not enqueue customer notifications for CLEVER-local CUSTOM orders', async () => { const { prisma } = createPrismaHarness({ routePlanFindFirst: routePlanRecord(), @@ -2830,7 +2947,11 @@ function expectRoutePlanVersionClaim( function routePlanRecord(input: { assignmentGeneration?: bigint; constraints?: Record; + deliveryWorkCompletedAt?: Date | null; + deliveryWorkCompletedGeneration?: bigint | null; + deliveryWorkCompletedVersionId?: string | null; driverId?: string | null; + driverNavigationUntil?: Date | null; driverEvents?: Array<{ eventType: string }>; driverRouteNotificationAttempts?: Array<{ createdAt: Date }>; metrics?: Record; @@ -2843,9 +2964,13 @@ function routePlanRecord(input: { assignmentGeneration: input.assignmentGeneration ?? 1n, createdAt: new Date('2026-05-07T12:30:00.000Z'), constraints: input.constraints ?? {}, + deliveryWorkCompletedAt: input.deliveryWorkCompletedAt ?? null, + deliveryWorkCompletedGeneration: input.deliveryWorkCompletedGeneration ?? null, + deliveryWorkCompletedVersionId: input.deliveryWorkCompletedVersionId ?? null, depotLatitude: '43.6532', depotLongitude: '-79.3832', driverId: input.driverId ?? null, + driverNavigationUntil: input.driverNavigationUntil ?? null, driverEvents: input.driverEvents, driverRouteNotificationAttempts: input.driverRouteNotificationAttempts, id: 'route-plan-id', @@ -2857,13 +2982,38 @@ function routePlanRecord(input: { }, name: 'CLEVER route draft', planDate: new Date('2026-05-08T00:00:00.000Z'), - routeGroupingChildVersions: input.routeGroupingChildVersions, + routeGroupingChildVersions: input.routeGroupingChildVersions ?? [], routeStops: input.routeStops ?? [], status: input.status ?? 'READY', updatedAt: input.updatedAt ?? new Date('2026-05-07T12:30:00.000Z') }; } +function completionReconciliationRoute(stopStatus: string, completed = false): Record { + const completedAt = completed ? new Date('2026-10-01T22:00:00.000Z') : null; + return { + assignmentGeneration: 1n, + deliveryWorkCompletedAt: completedAt, + deliveryWorkCompletedGeneration: completed ? 1n : null, + deliveryWorkCompletedVersionId: completed ? 'route-version-id' : null, + driverNavigationUntil: completed ? new Date('2026-10-02T00:00:00.000Z') : null, + id: 'route-plan-id', + routeGroupingChildVersions: [{ + id: 'route-version-id', + snapshot: { + membershipSchemaVersion: 1, + stops: [{ deliveryStopId: 'stop-1', orderId: 'order-1', sequence: 1 }] + } + }], + routeStops: [{ + deliveryStop: { order: { currentRouteVersionId: 'route-version-id' }, orderId: 'order-1', status: stopStatus }, + deliveryStopId: 'stop-1', + sequence: 1 + }], + status: 'IN_PROGRESS' + }; +} + function routePlanListRecord() { const stop = (input: { amount: string; diff --git a/apps/delivery-api/tests/stale-route-finalization.service.test.ts b/apps/delivery-api/tests/stale-route-finalization.service.test.ts index 39df31fc..37259030 100644 --- a/apps/delivery-api/tests/stale-route-finalization.service.test.ts +++ b/apps/delivery-api/tests/stale-route-finalization.service.test.ts @@ -8,7 +8,7 @@ import { } from '../src/modules/route-plans/stale-route-finalization.service.js'; describe('PrismaStaleRouteFinalizationService', () => { - test('finalizes only an exact K-food route after its tracking event window ends', async () => { + test('keeps the existing INCOMPLETE policy for a stale route with an unresolved ARRIVED stop', async () => { const harness = createHarness(); const service = new PrismaStaleRouteFinalizationService(harness.prisma); @@ -45,6 +45,38 @@ describe('PrismaStaleRouteFinalizationService', () => { }); }); + test('keeps a valid delivery-complete route in navigation grace before expiry', async () => { + const harness = createHarness({ navigationUntil: new Date('2026-09-20T01:00:00.000Z') }); + const service = new PrismaStaleRouteFinalizationService(harness.prisma); + + const result = await service.processDue(new Date('2026-09-20T00:59:59.999Z')); + + expect(result.skippedNotDue).toBe(1); + expect(harness.updateMany).not.toHaveBeenCalled(); + }); + + test('completes a valid delivery-complete route at the exact navigation expiry boundary', async () => { + const navigationUntil = new Date('2026-09-20T01:00:00.000Z'); + const harness = createHarness({ navigationUntil }); + const service = new PrismaStaleRouteFinalizationService(harness.prisma); + + const result = await service.processDue(navigationUntil); + + expect(result.finalized).toBe(1); + expect(harness.updateMany).toHaveBeenCalledWith({ + data: { status: 'COMPLETED' }, + where: expect.objectContaining({ + assignmentGeneration: 3n, + deliveryWorkCompletedGeneration: 3n, + deliveryWorkCompletedVersionId: 'version-id', + driverNavigationUntil: navigationUntil, + id: 'route-id', + shopId: 'shop-id', + status: 'IN_PROGRESS' + }) as unknown + }); + }); + test('does not finalize before the event window ends', async () => { const harness = createHarness(); const service = new PrismaStaleRouteFinalizationService(harness.prisma); @@ -120,6 +152,7 @@ function createHarness(overrides: { constraints?: unknown; driverEvents?: Array<{ eventType: string; occurredAt: Date }>; lockedRows?: Array<{ id: string }>; + navigationUntil?: Date; updatedCount?: number; } = {}) { const findMany = vi.fn().mockResolvedValue([{ @@ -127,7 +160,7 @@ function createHarness(overrides: { planDate: new Date('2026-09-17T00:00:00.000Z'), shopId: 'shop-id' }]); - const findFirst = vi.fn().mockResolvedValue({ + const staleRoute = { assignmentGeneration: 3n, constraints: overrides.constraints ?? { timezone: 'America/Toronto' }, driverEvents: overrides.driverEvents ?? [ @@ -137,6 +170,45 @@ function createHarness(overrides: { planDate: new Date('2026-09-17T00:00:00.000Z'), shopId: 'shop-id', updatedAt: new Date('2026-09-17T12:00:00.000Z') + }; + const completedAt = overrides.navigationUntil === undefined + ? null + : new Date(overrides.navigationUntil.getTime() - 2 * 60 * 60_000); + const completionRoute = { + assignmentGeneration: 3n, + deliveryWorkCompletedAt: completedAt, + deliveryWorkCompletedGeneration: overrides.navigationUntil === undefined ? null : 3n, + deliveryWorkCompletedVersionId: overrides.navigationUntil === undefined ? null : 'version-id', + driverNavigationUntil: overrides.navigationUntil ?? null, + id: 'route-id', + routeGroupingChildVersions: [{ + id: 'version-id', + snapshot: { + membershipSchemaVersion: 1, + stops: [{ deliveryStopId: 'stop-id', orderId: 'order-id', sequence: 1 }] + } + }], + routeStops: [{ + deliveryStopId: 'stop-id', + sequence: 1, + deliveryStop: { + orderId: 'order-id', + order: { currentRouteVersionId: 'version-id' }, + status: overrides.navigationUntil === undefined ? 'ARRIVED' : 'DELIVERED' + } + }], + status: 'IN_PROGRESS' + }; + const findFirst = vi.fn((query: { select?: { routeGroupingChildVersions?: unknown; deliveryWorkCompletedAt?: unknown } }) => { + if (query.select?.routeGroupingChildVersions !== undefined) return Promise.resolve(completionRoute); + if (query.select?.deliveryWorkCompletedAt !== undefined) return Promise.resolve({ + ...staleRoute, + deliveryWorkCompletedAt: completedAt, + deliveryWorkCompletedGeneration: overrides.navigationUntil === undefined ? null : 3n, + deliveryWorkCompletedVersionId: overrides.navigationUntil === undefined ? null : 'version-id', + driverNavigationUntil: overrides.navigationUntil ?? null + }); + return Promise.resolve(staleRoute); }); const updateMany = vi.fn().mockResolvedValue({ count: overrides.updatedCount ?? 1 }); const lockRoutePlan = vi.fn().mockResolvedValue(overrides.lockedRows ?? [{ id: 'route-id' }]); diff --git a/docs/deployment/kfood-delivery-navigation-grace.md b/docs/deployment/kfood-delivery-navigation-grace.md new file mode 100644 index 00000000..ba8e78b3 --- /dev/null +++ b/docs/deployment/kfood-delivery-navigation-grace.md @@ -0,0 +1,100 @@ +# K-food delivery completion and return navigation + +## Contract + +For app `clever-route-kfood` and shop `7hrud1-xq.myshopify.com`, an accepted +last terminal stop result records delivery completion immediately. Admin and +Shopify route list, detail, grouping, history and operational reads display +`COMPLETED`. The existing mobile execution contract retains raw `IN_PROGRESS` +for two hours so return-to-store navigation can remain available without a +mobile or Shopify release. + +The server records its receipt time in `deliveryWorkCompletedAt` and the fixed +two-hour deadline in `driverNavigationUntil`, bound to the assignment generation +and current immutable child version. It requires exactly one current child, +nonempty exact snapshot membership, matching order bindings, and every stop +`DELIVERED`, `FAILED`, `CANCELLED` or `SKIPPED`. `ARRIVED` is unresolved. +This records delivery work being resolved, not a claim that every order was +delivered successfully. + +Driver terminal events, accepted completion-assistance outcomes/return intent, +and administrative stop transitions reconcile completion under the route lock. +K-food receives a command-only completion run even when location detection is +disabled. Released apps require a valid policy to send the return command, so +this run uses an immutable internal policy and null coordinates for every +stop. Null coordinates prevent local visit detection; the run has no inference +activation and does not enable the location worker. An explicit return-to-store +command resolves the final stop from +`ARRIVED` to `DELIVERED` only when it is the sole unresolved stop, every earlier +stop is terminal, and the current account, assignment, child snapshot and order +bindings match. The server records `DRIVER_RETURN_INTENT` as the outcome source +and does not infer a delivery time from GPS. Pending or en-route stops, earlier +unresolved stops and stale assignments cannot be completed this way. +Repeats do not extend the deadline. Stop corrections invalidate the marker, and +child replacement clears markers owned by the replaced version. No historical +backfill or synthetic `ROUTE_COMPLETED` event occurs. + +## Mobile compatibility + +During the grace period, GPS and notes remain accepted; new execution, pause, +restart and reorder commands are rejected. A client `ROUTE_COMPLETED` command +is acknowledged as a `NOTE_ADDED` event with schema +`kfood_return_navigation_completion_ack_v1`, preserving the navigation period. +Its retry resolves to the same receipt. Previously committed stop retries also +resolve before execution-state validation. + +At the deadline, active account/route lookup and route-token validation exclude +the route even if the periodic worker has not run. A subsequent mobile refresh +or reconnection removes it from My Routes. This does not install a timer in an +already cached mobile screen or stop external map navigation. + +The existing `KFOOD_STALE_ROUTE_FINALIZATION_ENABLED` worker revalidates current +membership under the route lock and changes the raw status to `COMPLETED` after +the deadline. It scans every 15 minutes, in pages of 25; raw finalization can lag +the access deadline. Routes without an eligible completion marker retain the +existing tracking-window finalization to `INCOMPLETE`. The worker never derives +historical stop outcomes from an all-terminal snapshot alone. + +## Rollout and evidence + +Apply additive migration `20261002140000_kfood_delivery_navigation_grace` +through the reviewed manual deployment workflow before starting the new image. +It adds four nullable columns and updates no existing rows. No runtime config +change is required if the stale finalization worker is already enabled. If it +is disabled, access still expires but raw status awaits worker activation. + +Deployment requires explicit production authority. Preserve the existing +backup/restore rehearsal, migration approval and rollback evidence requirements +in the production runbooks. An older image does not maintain these markers +when a stop or child version changes. Before it can serve requests, stop the +candidate, durably back up marker-bearing K-food routes, conditionally clear +all four marker fields under route locks, and verify that none remain. The +simple deployment wrapper performs this guard before automatic image rollback. +It first requires zero deferred `kfood_return_navigation_completion_ack_v1` +events, because an older image cannot replay those receipts. If one exists, or +backup or cleanup fails, it must not start the older image; recover with a +compatible image or a forward fix. Manual rollback must use the same guard, +or use a marker-aware compatible image. Retain the +private backup for audit and do not restore markers after mutations by an +older server. Order/stop outcomes, route status and assignment generation are +preserved. Do not remove these columns or backfill completion times as part +of rollback. + +Before first rollout, verify the exact K-food tenant is unique, marker fields +have no existing values, deferred completion acknowledgements are absent, and +the previous runtime digest is pinned. After any acknowledgement is created, +that previous image is no longer an unconditional recovery option. + +Validate separately: + +1. Prisma generation, lint, typecheck, tests and server build. +2. Additive migration on a disposable PostgreSQL database and the last-stop, + explicit return intent, retry, client completion acknowledgement, correction + and expiry contracts, with location detection disabled. +3. Production migration, runtime revision, worker flag and health evidence. +4. Authenticated Shopify Complete views and the currently installed mobile + binary before/after the deadline. Local API tests do not prove these screens. + +Historical incidents with an unresolved final stop need authoritative outcome +evidence and a separately authorized, guarded repair. Deploying this policy +does not repair stored historical outcomes. diff --git a/docs/deployment/kfood-incomplete-route-finalization.md b/docs/deployment/kfood-incomplete-route-finalization.md index 161e3a7d..5071c77a 100644 --- a/docs/deployment/kfood-incomplete-route-finalization.md +++ b/docs/deployment/kfood-incomplete-route-finalization.md @@ -13,6 +13,12 @@ any stop or order status and does not append a synthetic driver event. ## Scope and guards +Routes with a valid current delivery-completion marker follow the +[two-hour return-navigation policy](kfood-delivery-navigation-grace.md): their +administrative status is already `COMPLETED`, and this worker finalizes their +raw status as `COMPLETED` after the navigation deadline. The tracking-window +`INCOMPLETE` guards below apply when no valid completion marker exists. + The runtime is deliberately fixed to this tenant identity: - app ID: `clever-route-kfood` diff --git a/docs/deployment/route-ops-simple-ssm-deploy.md b/docs/deployment/route-ops-simple-ssm-deploy.md index 66d3f26f..fab8e020 100644 --- a/docs/deployment/route-ops-simple-ssm-deploy.md +++ b/docs/deployment/route-ops-simple-ssm-deploy.md @@ -215,6 +215,17 @@ always stages the previous static image, even if normal forward deploy would ski static, because recovery integrity is more important than speed. Do not touch `caddy` unless the rollback is specifically an ingress change. +For K-food completion markers introduced by PR #477, an older API cannot safely +maintain marker ownership. The simple wrapper stops the candidate and backs up +and clears the four marker fields under route locks before automatic rollback. +Manual rollback must perform that same guarded cleanup and zero-marker audit +before starting an older API, or select a marker-aware image. Preserve the +private backup, existing order/stop outcomes, route status and assignment +generation. Do not restore stale markers after an older API has served writes. +An older API also cannot replay the deferred K-food completion acknowledgement. +Automatic legacy rollback requires zero such acknowledgements; otherwise it +fails closed and requires a compatible image or forward fix. + ## DB/schema risk boundary Image rollback is not database rollback. A deploy is `db-risk: true` when it diff --git a/scripts/ssm-simple-route-ops-deploy.sh b/scripts/ssm-simple-route-ops-deploy.sh index aa890121..cad9907f 100755 --- a/scripts/ssm-simple-route-ops-deploy.sh +++ b/scripts/ssm-simple-route-ops-deploy.sh @@ -377,11 +377,333 @@ firebase_credentials='' python3 -c 'import json,sys; value=json.load(open(sys.argv[1])); assert value.get("project_id") == "clever-routes-prod"; assert value.get("client_email")' "$FIREBASE_CREDENTIALS_FILE" chown 100:101 "$FIREBASE_CREDENTIALS_FILE" chmod 400 "$FIREBASE_CREDENTIALS_FILE" +clear_kfood_navigation_markers_for_legacy_rollback() { + rollback_evidence_dir="$APP_DIR/.deploy/kfood-legacy-rollback-evidence" + if ! install -d -m 0700 -o root -g root "$rollback_evidence_dir"; then + echo 'simple deploy rollback blocked: private marker backup directory could not be prepared' >&2 + return 1 + fi + docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-candidate-image.env -f "$COMPOSE_FILE" run --rm --no-deps -T \ + --user 0:0 \ + -e "KFOOD_ROLLBACK_EVIDENCE_HOST_DIR=$rollback_evidence_dir" \ + -v "$rollback_evidence_dir:/rollback-evidence" \ + --entrypoint node \ + clever-route-api <<'NODE' +const fs = require('node:fs'); +const path = require('node:path'); +const { createHash, randomUUID } = require('node:crypto'); +const { Client } = require('pg'); + +const markerColumns = [ + 'deliveryWorkCompletedAt', + 'driverNavigationUntil', + 'deliveryWorkCompletedGeneration', + 'deliveryWorkCompletedVersionId', +]; +const appId = 'clever-route-kfood'; +const shopDomain = '7hrud1-xq.myshopify.com'; +const backupDirectory = '/rollback-evidence'; +const backupDisplayDirectory = process.env.KFOOD_ROLLBACK_EVIDENCE_HOST_DIR || backupDirectory; + +function persistBackup(routes) { + const stamp = new Date().toISOString().replace(/[-:.]/g, ''); + const backupPath = path.join( + backupDirectory, + `kfood-navigation-markers-before-legacy-rollback-${stamp}-${randomUUID()}.json`, + ); + const handle = fs.openSync(backupPath, 'wx', 0o600); + const body = `${JSON.stringify({ + kind: 'kfood_navigation_markers_before_legacy_rollback_v1', + createdAt: new Date().toISOString(), + routes, + }, null, 2)}\n`; + try { + fs.writeFileSync(handle, body); + fs.fsyncSync(handle); + } finally { + fs.closeSync(handle); + } + fs.chmodSync(backupPath, 0o600); + const directoryHandle = fs.openSync(backupDirectory, 'r'); + try { + fs.fsyncSync(directoryHandle); + } finally { + fs.closeSync(directoryHandle); + } + return { + backupPath: path.join(backupDisplayDirectory, path.basename(backupPath)), + backupSha256: createHash('sha256').update(body).digest('hex'), + }; +} + +async function main() { + const client = new Client({ + connectionString: process.env.DATABASE_URL, + connectionTimeoutMillis: 10000, + query_timeout: 30000, + statement_timeout: 30000, + }); + await client.connect(); + try { + const shops = await client.query( + `SELECT "id" + FROM "shops" + WHERE "appId" = $1 + AND "shopDomain" = $2`, + [appId, shopDomain], + ); + if (shops.rowCount !== 1) { + throw new Error(`legacy rollback marker guard: expected one exact K-food shop, found ${shops.rowCount}`); + } + const shopId = shops.rows[0].id; + const completionAcks = await client.query( + `SELECT count(*)::int AS "count" + FROM "driver_events" + WHERE "shopId" = $1 + AND "eventType"::text = 'NOTE_ADDED' + AND "payload"->>'schema' = 'kfood_return_navigation_completion_ack_v1' + AND "payload"->>'requestedEventType' = 'ROUTE_COMPLETED'`, + [shopId], + ); + if (completionAcks.rows[0].count !== 0) { + throw new Error(`legacy rollback marker guard: ${completionAcks.rows[0].count} deferred completion acknowledgement(s) require a compatible image or forward fix`); + } + + const columns = await client.query( + `SELECT "column_name" + FROM information_schema.columns + WHERE "table_schema" = ANY (current_schemas(false)::text[]) + AND "table_name" = 'route_plans' + AND "column_name" = ANY ($1::text[])`, + [markerColumns], + ); + if (columns.rowCount === 0) { + console.log('legacy rollback marker guard: marker columns are absent; no cleanup required'); + return; + } + if (columns.rowCount !== markerColumns.length) { + throw new Error(`legacy rollback marker guard: partial marker schema (${columns.rowCount}/${markerColumns.length})`); + } + + await client.query('BEGIN'); + try { + await client.query("SET LOCAL lock_timeout = '10s'"); + await client.query("SET LOCAL statement_timeout = '30s'"); + const locked = await client.query( + `SELECT rp."id", + rp."shopId", + rp."status"::text AS "status", + rp."assignmentGeneration", + to_char(rp."updatedAt", 'YYYY-MM-DD"T"HH24:MI:SS.USOF') AS "updatedAt", + to_char(rp."deliveryWorkCompletedAt", 'YYYY-MM-DD"T"HH24:MI:SS.USOF') AS "deliveryWorkCompletedAt", + to_char(rp."driverNavigationUntil", 'YYYY-MM-DD"T"HH24:MI:SS.USOF') AS "driverNavigationUntil", + rp."deliveryWorkCompletedGeneration", + rp."deliveryWorkCompletedVersionId" + FROM "route_plans" rp + WHERE rp."shopId" = $1 + AND (rp."deliveryWorkCompletedAt" IS NOT NULL + OR rp."driverNavigationUntil" IS NOT NULL + OR rp."deliveryWorkCompletedGeneration" IS NOT NULL + OR rp."deliveryWorkCompletedVersionId" IS NOT NULL) + ORDER BY rp."id" + FOR UPDATE OF rp`, + [shopId], + ); + if (locked.rowCount === 0) { + await client.query('COMMIT'); + console.log('legacy rollback marker guard: no K-food navigation markers found'); + return; + } + + const routeIds = locked.rows.map((row) => row.id); + const stopStatuses = await client.query( + `SELECT rps."routePlanId", rps."sequence", ds."id" AS "deliveryStopId", ds."status"::text AS "status" + FROM "route_plan_stops" rps + JOIN "delivery_stops" ds + ON ds."id" = rps."deliveryStopId" + AND ds."shopId" = rps."shopId" + WHERE rps."routePlanId" = ANY ($1::uuid[]) + ORDER BY rps."routePlanId", rps."sequence", ds."id"`, + [routeIds], + ); + const stopDigestByRoute = new Map(); + for (const routeId of routeIds) { + const digestInput = stopStatuses.rows + .filter((row) => row.routePlanId === routeId) + .map((row) => [row.sequence, row.deliveryStopId, row.status]); + stopDigestByRoute.set(routeId, createHash('sha256').update(JSON.stringify(digestInput)).digest('hex')); + } + const backupRows = locked.rows.map((row) => ({ + ...row, + stopStatusDigest: stopDigestByRoute.get(row.id), + })); + const { backupPath, backupSha256 } = persistBackup(backupRows); + + let clearedCount = 0; + for (const route of locked.rows) { + const cleared = await client.query( + `UPDATE "route_plans" + SET "deliveryWorkCompletedAt" = NULL, + "driverNavigationUntil" = NULL, + "deliveryWorkCompletedGeneration" = NULL, + "deliveryWorkCompletedVersionId" = NULL + WHERE "id" = $1 + AND "shopId" = $2 + AND "status"::text = $3 + AND "assignmentGeneration" = $4::bigint + AND "updatedAt" = $5 + AND "deliveryWorkCompletedAt" IS NOT DISTINCT FROM $6::timestamptz + AND "driverNavigationUntil" IS NOT DISTINCT FROM $7::timestamptz + AND "deliveryWorkCompletedGeneration" IS NOT DISTINCT FROM $8::bigint + AND "deliveryWorkCompletedVersionId" IS NOT DISTINCT FROM $9::uuid`, + [ + route.id, + route.shopId, + route.status, + route.assignmentGeneration, + route.updatedAt, + route.deliveryWorkCompletedAt, + route.driverNavigationUntil, + route.deliveryWorkCompletedGeneration, + route.deliveryWorkCompletedVersionId, + ], + ); + if (cleared.rowCount !== 1) { + throw new Error(`legacy rollback marker guard: compare-and-clear failed for route ${route.id}`); + } + clearedCount += 1; + } + const audit = await client.query( + `SELECT count(*)::int AS "remaining" + FROM "route_plans" rp + WHERE rp."shopId" = $1 + AND (rp."deliveryWorkCompletedAt" IS NOT NULL + OR rp."driverNavigationUntil" IS NOT NULL + OR rp."deliveryWorkCompletedGeneration" IS NOT NULL + OR rp."deliveryWorkCompletedVersionId" IS NOT NULL)`, + [shopId], + ); + if (audit.rows[0].remaining !== 0) { + throw new Error(`legacy rollback marker guard: ${audit.rows[0].remaining} markers remain after cleanup`); + } + const preservedRoutes = await client.query( + `SELECT "id", + "status"::text AS "status", + "assignmentGeneration", + to_char("updatedAt", 'YYYY-MM-DD"T"HH24:MI:SS.USOF') AS "updatedAt" + FROM "route_plans" + WHERE "id" = ANY ($1::uuid[]) + ORDER BY "id"`, + [routeIds], + ); + for (const before of locked.rows) { + const after = preservedRoutes.rows.find((row) => row.id === before.id); + if (!after + || after.status !== before.status + || after.assignmentGeneration !== before.assignmentGeneration + || after.updatedAt !== before.updatedAt) { + throw new Error(`legacy rollback marker guard: route state changed while clearing markers for ${before.id}`); + } + } + const stopStatusesAfter = await client.query( + `SELECT rps."routePlanId", rps."sequence", ds."id" AS "deliveryStopId", ds."status"::text AS "status" + FROM "route_plan_stops" rps + JOIN "delivery_stops" ds + ON ds."id" = rps."deliveryStopId" + AND ds."shopId" = rps."shopId" + WHERE rps."routePlanId" = ANY ($1::uuid[]) + ORDER BY rps."routePlanId", rps."sequence", ds."id"`, + [routeIds], + ); + for (const routeId of routeIds) { + const digestInput = stopStatusesAfter.rows + .filter((row) => row.routePlanId === routeId) + .map((row) => [row.sequence, row.deliveryStopId, row.status]); + const digestAfter = createHash('sha256').update(JSON.stringify(digestInput)).digest('hex'); + if (digestAfter !== stopDigestByRoute.get(routeId)) { + throw new Error(`legacy rollback marker guard: stop status digest changed for route ${routeId}`); + } + } + await client.query('COMMIT'); + console.log(`legacy rollback marker guard: cleared ${clearedCount} route(s); backup=${backupPath} backupSha256=${backupSha256}`); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } + } finally { + await client.end(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); +}); +NODE +} +contain_failed_rollback_api() { + if ! rollback_api_ids="$(docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" ps -a -q clever-route-api)"; then + echo 'simple deploy rollback containment failed: rollback API container could not be resolved; manual containment required' >&2 + return 1 + fi + if [ -z "$rollback_api_ids" ]; then + return 0 + fi + if ! docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" stop --timeout 30 clever-route-api; then + echo 'simple deploy rollback containment failed: rollback API stop command failed; manual containment required' >&2 + return 1 + fi + for rollback_api_id in $rollback_api_ids; do + if ! rollback_api_running="$(docker inspect "$rollback_api_id" --format '{{.State.Running}}')"; then + echo 'simple deploy rollback containment failed: rollback API state could not be inspected; manual containment required' >&2 + return 1 + fi + if [ "$rollback_api_running" != "false" ]; then + echo 'simple deploy rollback containment failed: rollback API remains running; manual containment required' >&2 + return 1 + fi + done + echo 'simple deploy rollback containment verified: rollback API is stopped' >&2 +} rollback_delivery_api() { echo 'simple deploy health failed; rolling clever-route-api back to previous image env' >&2 - docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" --profile osrm --profile vroom --profile korea pull clever-route-api route-ops-web-static vroom vroom-korea - docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" up --no-build --force-recreate route-ops-web-static - docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" up -d --no-build --no-deps --force-recreate --remove-orphans clever-route-api + if ! candidate_api_id="$(docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-candidate-image.env -f "$COMPOSE_FILE" ps -a -q clever-route-api)"; then + echo 'simple deploy rollback blocked: candidate clever-route-api container could not be resolved' >&2 + return 1 + fi + if [ -z "$candidate_api_id" ]; then + echo 'simple deploy rollback blocked: candidate clever-route-api container is missing' >&2 + return 1 + fi + if ! docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-candidate-image.env -f "$COMPOSE_FILE" stop --timeout 30 clever-route-api; then + echo 'simple deploy rollback blocked: candidate clever-route-api could not be stopped' >&2 + return 1 + fi + if ! candidate_api_running="$(docker inspect "$candidate_api_id" --format '{{.State.Running}}')"; then + echo 'simple deploy rollback blocked: candidate clever-route-api state could not be inspected' >&2 + return 1 + fi + if [ "$candidate_api_running" != "false" ]; then + echo 'simple deploy rollback blocked: candidate clever-route-api is still running' >&2 + return 1 + fi + if ! clear_kfood_navigation_markers_for_legacy_rollback; then + echo 'simple deploy rollback blocked: K-food navigation markers were not safely cleared' >&2 + return 1 + fi + if ! docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" --profile osrm --profile vroom --profile korea pull clever-route-api route-ops-web-static vroom vroom-korea; then + echo 'simple deploy rollback failed: previous images could not be pulled' >&2 + return 1 + fi + if ! docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" up --no-build --force-recreate route-ops-web-static; then + echo 'simple deploy rollback failed: previous static artifact could not be staged' >&2 + return 1 + fi + if ! docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" up -d --no-build --no-deps --force-recreate --remove-orphans clever-route-api; then + echo 'simple deploy rollback failed: previous clever-route-api could not be started' >&2 + contain_failed_rollback_api || true + return 1 + fi for rollback_attempt in $(seq 1 30); do if smoke_health; then echo 'simple deploy rollback completed; previous clever-route-api is healthy' >&2 @@ -390,6 +712,7 @@ rollback_delivery_api() { sleep 2 done echo 'simple deploy rollback failed health check; manual intervention required' >&2 + contain_failed_rollback_api || true return 1 } rollback_retention_runtime() { @@ -560,7 +883,12 @@ fi docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-candidate-image.env -f "$COMPOSE_FILE" up -d --no-build --no-deps --force-recreate --remove-orphans clever-route-api for attempt in $(seq 1 30); do if smoke_health; then break; fi - if [ "$attempt" = "30" ]; then rollback_delivery_api || true; exit 1; fi + if [ "$attempt" = "30" ]; then + if ! rollback_delivery_api; then + echo 'simple deploy automatic rollback did not reach a verified healthy state; inspect rollback and containment diagnostics' >&2 + fi + exit 1 + fi sleep 2 done cp .deploy/current-image.env ".deploy/current-image.env.before-simple-$(date -u +%Y%m%dT%H%M%SZ)" 2>/dev/null || true @@ -575,7 +903,9 @@ if ! CLEVER_ROUTE_RETENTION_RUNNER_SOURCE="$APP_DIR/.deploy/candidate-retention/ rm -f .deploy/current-image.env fi rollback_retention_runtime || true - rollback_delivery_api || true + if ! rollback_delivery_api; then + echo 'retention failure rollback did not reach a verified healthy state; inspect rollback and containment diagnostics' >&2 + fi exit 1 fi .deploy/route-ops-docker-cleanup.sh --enforce diff --git a/tests/deploy/ssm-simple-route-ops-deploy.test.sh b/tests/deploy/ssm-simple-route-ops-deploy.test.sh index cdd42531..7270e5a7 100755 --- a/tests/deploy/ssm-simple-route-ops-deploy.test.sh +++ b/tests/deploy/ssm-simple-route-ops-deploy.test.sh @@ -144,7 +144,74 @@ checks = { 'does_not_recreate_caddy': '--force-recreate --remove-orphans clever-route-api caddy' not in command and '--force-recreate clever-route-api caddy' not in command, 'does_not_push_prod_prev': 'backup_channel_images' not in wrapper and 'previous_image_ref' not in wrapper and 'docker tag' not in wrapper, 'rollback_uses_previous_env': 'cp .deploy/current-image.env .deploy/simple-rollback-image.env' in command and 'rolling clever-route-api back to previous image env' in command, - 'retention_install_failure_restores_previous_runtime': 'if ! CLEVER_ROUTE_RETENTION_RUNNER_SOURCE=' in command and '.deploy/install-driver-event-attempt-retention.sh; then' in command and 'cp .deploy/simple-rollback-image.env .deploy/current-image.env' in command and 'rollback_retention_runtime || true' in command and command.index('rollback_retention_runtime || true', command.index('if ! CLEVER_ROUTE_RETENTION_RUNNER_SOURCE=')) < command.index('rollback_delivery_api || true', command.index('if ! CLEVER_ROUTE_RETENTION_RUNNER_SOURCE=')), + 'legacy_rollback_stops_candidate_before_marker_cleanup_and_old_start': all(value in host_script for value in [ + 'clear_kfood_navigation_markers_for_legacy_rollback()', + 'ps -a -q clever-route-api', + '--env-file .deploy/simple-candidate-image.env -f "$COMPOSE_FILE" stop --timeout 30 clever-route-api', + "--format '{{.State.Running}}'", + 'candidate clever-route-api is still running', + 'install -d -m 0700 -o root -g root "$rollback_evidence_dir"', + 'private marker backup directory could not be prepared', + '--user 0:0', + '-e "KFOOD_ROLLBACK_EVIDENCE_HOST_DIR=$rollback_evidence_dir"', + '-v "$rollback_evidence_dir:/rollback-evidence"', + 'kfood-navigation-markers-before-legacy-rollback-', + "fs.openSync(backupPath, 'wx', 0o600)", + 'fs.fsyncSync(handle)', + 'FOR UPDATE OF rp', + "'kfood_return_navigation_completion_ack_v1'", + "'ROUTE_COMPLETED'", + 'deferred completion acknowledgement(s) require a compatible image or forward fix', + 'connectionTimeoutMillis: 10000', + 'query_timeout: 30000', + "SET LOCAL lock_timeout = '10s'", + "SET LOCAL statement_timeout = '30s'", + 'legacy rollback marker guard: no K-food navigation markers found', + 'legacy rollback marker guard: marker columns are absent; no cleanup required', + 'legacy rollback marker guard: partial marker schema', + 'legacy rollback marker guard: cleared ${clearedCount} route(s); backup=${backupPath} backupSha256=${backupSha256}', + ]) and command.index('stop --timeout 30 clever-route-api') < command.index('clear_kfood_navigation_markers_for_legacy_rollback; then') < command.index('--env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" --profile osrm --profile vroom --profile korea pull clever-route-api'), + 'legacy_rollback_marker_cleanup_is_exact_and_non_destructive': all(value in host_script for value in [ + "const appId = 'clever-route-kfood'", + "const shopDomain = '7hrud1-xq.myshopify.com'", + 'expected one exact K-food shop', + 'FROM "shops"', + 'FROM "route_plans" rp', + 'UPDATE "route_plans"', + "AND \"table_name\" = 'route_plans'", + 'rp."assignmentGeneration"', + 'rp."updatedAt"', + 'rp."deliveryWorkCompletedAt"', + 'rp."driverNavigationUntil"', + 'rp."deliveryWorkCompletedGeneration"', + 'rp."deliveryWorkCompletedVersionId"', + 'stopStatusDigest', + "to_char(rp.\"updatedAt\", 'YYYY-MM-DD\"T\"HH24:MI:SS.USOF')", + "backupSha256: createHash('sha256').update(body).digest('hex')", + 'SET "deliveryWorkCompletedAt" = NULL', + '"updatedAt" = $5', + 'IS NOT DISTINCT FROM $9::uuid', + 'if (cleared.rowCount !== 1)', + 'if (audit.rows[0].remaining !== 0)', + 'stop status digest changed', + ]) and 'SET "status"' not in host_script and 'INSERT INTO "DriverEvent"' not in host_script, + 'legacy_rollback_guard_failure_blocks_old_runtime': 'if ! clear_kfood_navigation_markers_for_legacy_rollback; then' in command and 'simple deploy rollback blocked: K-food navigation markers were not safely cleared' in command and command.index('simple deploy rollback blocked: K-food navigation markers were not safely cleared') < command.index('--env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" --profile osrm --profile vroom --profile korea pull clever-route-api'), + 'legacy_rollback_old_runtime_steps_fail_closed': all(value in command for value in [ + 'if ! docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" --profile osrm --profile vroom --profile korea pull clever-route-api route-ops-web-static vroom vroom-korea; then', + 'previous images could not be pulled', + 'if ! docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" up --no-build --force-recreate route-ops-web-static; then', + 'previous static artifact could not be staged', + 'if ! docker compose -p "$COMPOSE_PROJECT" --env-file .deploy/simple-rollback-image.env -f "$COMPOSE_FILE" up -d --no-build --no-deps --force-recreate --remove-orphans clever-route-api; then', + 'previous clever-route-api could not be started', + 'contain_failed_rollback_api()', + 'rollback containment failed: rollback API container could not be resolved; manual containment required', + 'rollback containment failed: rollback API stop command failed; manual containment required', + 'rollback containment failed: rollback API state could not be inspected; manual containment required', + 'rollback containment failed: rollback API remains running; manual containment required', + 'rollback containment verified: rollback API is stopped', + ]) and command.count('contain_failed_rollback_api || true') == 2 and command.index('previous clever-route-api could not be started') < command.index('contain_failed_rollback_api || true') < command.index('simple deploy rollback failed health check; manual intervention required'), + 'legacy_rollback_failure_is_reported_and_preserves_failing_exit': 'simple deploy automatic rollback did not reach a verified healthy state; inspect rollback and containment diagnostics' in command and 'retention failure rollback did not reach a verified healthy state; inspect rollback and containment diagnostics' in command and 'if ! rollback_delivery_api; then' in command and 'old clever-route-api was not started' not in command, + 'retention_install_failure_restores_previous_runtime': 'if ! CLEVER_ROUTE_RETENTION_RUNNER_SOURCE=' in command and '.deploy/install-driver-event-attempt-retention.sh; then' in command and 'cp .deploy/simple-rollback-image.env .deploy/current-image.env' in command and 'rollback_retention_runtime || true' in command and command.index('rollback_retention_runtime || true', command.index('if ! CLEVER_ROUTE_RETENTION_RUNNER_SOURCE=')) < command.index('if ! rollback_delivery_api; then', command.index('if ! CLEVER_ROUTE_RETENTION_RUNNER_SOURCE=')), 'retention_rollback_restores_or_removes_candidate_units': 'retention-rollback/service.present' in command and 'rm -f /etc/systemd/system/clever-driver-event-attempt-retention.service' in command and 'systemctl disable --now clever-driver-event-attempt-retention.timer' in command, 'proof_reservation_rollout_requires_compatible_rollback': 'DRIVER_PROOF_MEDIA_READY_FILTER_COMPATIBLE=$([ -n "$PROOF_READY_FILTER_CONTRACT_SHA" ] && echo true || echo false)' in command and 'DRIVER_PROOF_MEDIA_READY_FILTER_CONTRACT_SHA=$PROOF_READY_FILTER_CONTRACT_SHA' in command and 'if [ "$rollback_ready_filter_compatible" != "true" ]; then' in command and 'proof_reservations_enabled=' not in command and 'proof media reservation rollout blocked: rollback image does not advertise READY-only reads' in command, 'completion_invariant_candidate_and_rollback_capability': 'ROUTE_COMPLETION_INVARIANT_CAPABILITY_VERSION=1' in command and 'rollback image does not advertise invariant capability v1' in command and 'candidate delivery API does not advertise route completion invariant capability v1' in command and 'org.clever-route.route-completion-invariant-capability' in command,