Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions apps/delivery-api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions apps/delivery-api/scripts/test-disposable-db-integrations.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
181 changes: 167 additions & 14 deletions apps/delivery-api/src/modules/driver/completion-assistance.service.ts

Large diffs are not rendered by default.

87 changes: 68 additions & 19 deletions apps/delivery-api/src/modules/driver/driver-event.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -663,6 +683,7 @@ async function lockRoutePlanForSerializedEvent(
&& input.eventType !== 'STOP_FAILED'
&& input.eventType !== 'LOCATION_UPDATED'
&& input.eventType !== 'ROUTE_COMPLETED'
&& input.eventType !== 'ROUTE_PAUSED'
) {
return;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
);
Expand All @@ -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<string, unknown>;
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<string, unknown>).changeRequestId;
Expand Down Expand Up @@ -1218,20 +1247,30 @@ function readCompletionSnapshotStopIds(snapshot: Prisma.JsonValue): string[] {
async function validateDriverEventStateContext(
prisma: DriverEventTransactionClient,
input: RecordDriverEventInput,
shopId: string
): Promise<void> {
shopId: string,
now: Date
): Promise<boolean> {
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'
&& (
Expand Down Expand Up @@ -1271,6 +1310,7 @@ async function validateDriverEventStateContext(
shopId
});
}
return input.eventType === 'ROUTE_COMPLETED' && hasDeliveryWorkCompleted(routePlan);
}

async function applyDriverEventStateTransition(
Expand Down Expand Up @@ -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<void> {
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' } },
Expand All @@ -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, {
Expand All @@ -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<DeliveryWorkCompletionRecord> {
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' } },
Expand All @@ -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(
Expand Down
Loading
Loading