From ee45689ae9e1d4cc59502d0c4ebd71f69a9d759c Mon Sep 17 00:00:00 2001 From: OziinG Date: Fri, 2 Oct 2026 22:31:57 +0900 Subject: [PATCH 1/2] Keep K-food delivery completion accurate during return navigation Record resolved delivery work immediately and preserve the existing mobile execution contract until a fixed two-hour navigation deadline. Revalidate ownership and stop corrections under the route lock. Constraint: Existing mobile and Shopify releases must remain compatible. Rejected: Delay operational completion for two hours | Completed work would remain active in Shopify. Confidence: high Scope-risk: moderate Directive: Preserve generation and current-child ownership; do not backfill historical outcomes. Tested: Prisma generation/validation, full lint/typecheck/build, 3014 tests including 4 PostgreSQL contracts, additive migration, independent review. Not-tested: Production deployment and authenticated Shopify/mobile deadline behavior. --- .../migration.sql | 7 + apps/delivery-api/prisma/schema.prisma | 4 + .../test-disposable-db-integrations.sh | 5 + .../driver/completion-assistance.service.ts | 9 +- .../modules/driver/driver-event.repository.ts | 87 +++++++-- .../driver/driver-route-access.repository.ts | 95 +++++++--- .../driver/driver-route-order.service.ts | 4 +- .../driver/driver-self-service.repository.ts | 15 +- .../driver/driver-token-access.repository.ts | 20 +- .../route-grouping/route-grouping.service.ts | 46 ++++- .../route-plans/kfood-delivery-completion.ts | 134 ++++++++++++++ .../route-plans/route-plan.repository.ts | 29 ++- .../stale-route-finalization.service.ts | 50 +++++ .../route-operational-state.service.ts | 20 +- .../modules/shopify/order-sync.repository.ts | 85 ++++++++- .../tests/driver-event.repository.test.ts | 4 +- .../driver-route-access.repository.test.ts | 136 +++++++++++++- .../tests/driver-route-order.service.test.ts | 12 +- .../driver-self-service.repository.test.ts | 40 ++++ .../driver-token-access.repository.test.ts | 60 +++++- .../tests/dsv-g007-prisma-history.test.ts | 5 +- ...od-delivery-completion.integration.test.ts | 173 ++++++++++++++++++ .../tests/kfood-delivery-completion.test.ts | 124 +++++++++++++ .../tests/order-sync.repository.test.ts | 127 +++++++++++++ .../tests/route-grouping.service.test.ts | 44 ++++- .../route-operational-state.service.test.ts | 40 +++- .../tests/route-plan.repository.test.ts | 152 ++++++++++++++- .../stale-route-finalization.service.test.ts | 76 +++++++- .../kfood-delivery-navigation-grace.md | 73 ++++++++ .../kfood-incomplete-route-finalization.md | 6 + 30 files changed, 1599 insertions(+), 83 deletions(-) create mode 100644 apps/delivery-api/prisma/migrations/20261002140000_kfood_delivery_navigation_grace/migration.sql create mode 100644 apps/delivery-api/src/modules/route-plans/kfood-delivery-completion.ts create mode 100644 apps/delivery-api/tests/kfood-delivery-completion.integration.test.ts create mode 100644 apps/delivery-api/tests/kfood-delivery-completion.test.ts create mode 100644 docs/deployment/kfood-delivery-navigation-grace.md 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..0cd5cf51 100644 --- a/apps/delivery-api/src/modules/driver/completion-assistance.service.ts +++ b/apps/delivery-api/src/modules/driver/completion-assistance.service.ts @@ -3,6 +3,7 @@ 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 { reconcileKfoodDeliveryWorkCompletion } from '../route-plans/kfood-delivery-completion.js'; const DAY_MS = 86_400_000; const NONTERMINAL = new Set(['PENDING', 'ASSIGNED', 'EN_ROUTE', 'ARRIVED']); @@ -99,7 +100,12 @@ export class PrismaCompletionAssistanceService { let result: CompletionAcknowledgement; 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 = ack(command, run.invalidatedAt === null ? 'applied' : 'rejected', null, run.invalidatedAt === null ? undefined : 'run_invalidated'); + if (result.status === 'applied') await reconcileKfoodDeliveryWorkCompletion(tx, { + routePlanId: run.routePlanId, shopId: run.shopId, now + }); + } await tx.driverCompletionReceipt.create({ data: { accountId, commandId: command.commandId, runId: run.id, candidateId: result.candidate?.candidateId ?? null, @@ -324,6 +330,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; } 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/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..c50c948e --- /dev/null +++ b/docs/deployment/kfood-delivery-navigation-grace.md @@ -0,0 +1,73 @@ +# 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. +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. Returning to an older server image loses immediate +completion projection and access-expiry behavior for existing markers; retain +a compatible image for rollback. Do not remove these columns or backfill +completion times as part of rollback. + +Validate separately: + +1. Prisma generation, lint, typecheck, tests and server build. +2. Additive migration on a disposable PostgreSQL database and the last-stop, + retry, client completion acknowledgement, correction and expiry contracts. +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` From 4a8350d342fb648e5286868feafd683c08287ab3 Mon Sep 17 00:00:00 2001 From: OziinG Date: Fri, 2 Oct 2026 23:33:13 +0900 Subject: [PATCH 2/2] Let released drivers complete K-food work while returning to the store Existing released apps need a valid command context to submit their return intent. Keep that context inert for GPS inference and guard legacy rollback against stale markers and incompatible deferred completion receipts. Constraint: Preserve mobile 1.3.3 and 1.3.4 without a mobile release. Rejected: Null-policy return context | Released apps never submit its return command. Confidence: high Scope-risk: moderate Directive: Legacy rollback requires zero deferred completion acknowledgements and guarded marker cleanup. Tested: Released app reducer contract, focused PostgreSQL return cases, real rollback guard cases, production-backup restore and additive migration twice with zero drift, independent review. Not-tested: Latest exact-commit full CI and production deployment are pending. --- .../driver/completion-assistance.service.ts | 182 +++++++++- .../completion-assistance.integration.test.ts | 209 +++++++++++ .../kfood-delivery-navigation-grace.md | 37 +- .../deployment/route-ops-simple-ssm-deploy.md | 11 + scripts/ssm-simple-route-ops-deploy.sh | 340 +++++++++++++++++- .../ssm-simple-route-ops-deploy.test.sh | 69 +++- 6 files changed, 819 insertions(+), 29 deletions(-) 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 0cd5cf51..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,10 +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 { reconcileKfoodDeliveryWorkCompletion } from '../route-plans/kfood-delivery-completion.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; @@ -44,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[] = []; @@ -61,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, @@ -98,13 +131,12 @@ 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'); - if (result.status === 'applied') await reconcileKfoodDeliveryWorkCompletion(tx, { - routePlanId: run.routePlanId, shopId: run.shopId, now - }); + result = await this.returnIntent(tx, run, command, now); } await tx.driverCompletionReceipt.create({ data: { accountId, commandId: command.commandId, runId: run.id, @@ -159,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 } }); @@ -186,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: { @@ -372,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/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/docs/deployment/kfood-delivery-navigation-grace.md b/docs/deployment/kfood-delivery-navigation-grace.md index c50c948e..ba8e78b3 100644 --- a/docs/deployment/kfood-delivery-navigation-grace.md +++ b/docs/deployment/kfood-delivery-navigation-grace.md @@ -19,6 +19,17 @@ 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. @@ -54,16 +65,32 @@ 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. Returning to an older server image loses immediate -completion projection and access-expiry behavior for existing markers; retain -a compatible image for rollback. Do not remove these columns or backfill -completion times as part of rollback. +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, - retry, client completion acknowledgement, correction and expiry contracts. + 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. 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,