added backlog

This commit is contained in:
Jun-te Kim 2026-04-20 14:58:52 +00:00
parent e6a71e4e0c
commit 776c88409c
18 changed files with 462 additions and 97 deletions

View file

@ -2,7 +2,13 @@
"permissions": {
"allow": [
"Bash(backlog task *)",
"Bash(backlog mcp *)"
"Bash(backlog mcp *)",
"Read(//home/vscode/.config/nvim/**)",
"Read(//home/vscode/.config/nvim/lua/plugins/**)",
"Bash(npx tsc *)"
]
}
},
"enabledMcpjsonServers": [
"backlog"
]
}

View file

@ -1,4 +1,4 @@
FROM library/python:3.12-bullseye
FROM library/python:3.12-bookworm
ARG USER=vscode
ARG USER_UID=1000
@ -43,8 +43,19 @@ RUN npm install -g backlog.md
# RUN apt-get install terraform
# RUN terraform -install-autocomplete
# Install Neovim (latest) + LazyVim deps
RUN curl -fsSL https://github.com/neovim/neovim/releases/latest/download/nvim-linux-x86_64.tar.gz \
| tar -xz -C /opt \
&& ln -s /opt/nvim-linux-x86_64/bin/nvim /usr/local/bin/nvim \
&& apt update && apt install -y --no-install-recommends \
ripgrep fd-find git make unzip \
&& rm -rf /var/lib/apt/lists/*
# Install Claude
USER ${USER}
# Bootstrap LazyVim starter config
RUN git clone https://github.com/LazyVim/starter /home/${USER}/.config/nvim \
&& rm -rf /home/${USER}/.config/nvim/.git
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& export PATH="/home/${USER}/.local/bin:${PATH}" \
&& claude plugin marketplace add JuliusBrussee/caveman \

View file

@ -21,7 +21,8 @@
},
"extensions": [
"esbenp.prettier-vscode",
"Anthropic.claude-code"
"Anthropic.claude-code",
"asvetliakov.vscode-neovim"
]
}
}

View file

@ -9,8 +9,12 @@ services:
command: sleep infinity
ports:
- "3000:3000"
- "6420:6420"
volumes:
- ..:/workspaces/assessment-model
- ~/.gitconfig:/home/vscode/.gitconfig:ro
environment:
- SSH_AUTH_SOCK=${SSH_AUTH_SOCK:-}
networks:
- frontend-net

View file

@ -15,3 +15,19 @@
- Tasks live as markdown under `backlog/tasks/`. Committed to git. Read them for context on outstanding manual work (env vars, IAM, infra) owed by humans.
- To start the web UI during development: `backlog browser` (port 6420, forwarded by devcontainer).
- Do NOT mirror Backlog.md tasks into Claude's internal todo system. Use one or the other — Backlog for durable cross-session work, internal todos for within-turn progress tracking.
## Development workflow (spec-driven)
Follow this loop for all feature work:
1. **Decompose** — split user request into small Backlog tasks with acceptance criteria. One task = one PR = one session.
2. **Plan first** — before writing code, research codebase and write implementation plan inside the task. Stop and wait for user approval.
3. **Implement** — only after plan approved. One task at a time.
4. **Verify** — run tests/lint, confirm output matches acceptance criteria.
**Hard rules:**
- Never start coding without an approved plan in the task.
- Never work on multiple tasks in one session.
- If task too big to finish in one session, split it first.

View file

@ -1,26 +0,0 @@
---
id: TASK-1
title: Add BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME to staging and prod env
status: To Do
assignee:
- Jun-te Kim
created_date: '2026-04-18 19:01'
labels:
- env
- infra
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Dev .env.local has it; non-dev envs still missing. Combine route returns 500 'Server misconfiguration' without it.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Value set in staging env: bulk-address2uprn-combiner-queue-staging (or matching stage suffix)
- [ ] #2 Value set in prod env: bulk-address2uprn-combiner-queue-prod
- [ ] #3 Deploy redeployed; /api/portfolio/{pid}/bulk-uploads/{uid}/combine returns 200 not 500
<!-- AC:END -->

View file

@ -0,0 +1,31 @@
---
id: TASK-10
title: Redirect to confirm-matches when combined_output_s3_uri populated
status: To Do
assignee: []
created_date: '2026-04-20'
updated_date: '2026-04-20'
labels:
- frontend
- bulk-upload
- ui
dependencies:
- TASK-8
priority: medium
ordinal: 5000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`OnboardingProgress.tsx` currently fires client-side combine POST when task terminal. Once backend auto-chains (backend-task-5) OR frontend triggers via backend route (task-7), the polling should watch for `bulk_address_uploads.combined_output_s3_uri` to be set. When present, show "Review matches →" CTA (or auto-redirect to confirm-matches page, task-8).
May require a new GET endpoint that returns `{status, combined_output_s3_uri}` for polling. Or extend existing task summary with upload fields.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Polling stops once combined_output_s3_uri populated
- [ ] #2 UI surfaces a clear CTA to review matches
- [ ] #3 No duplicate combiner fires across refreshes
<!-- AC:END -->

View file

@ -1,25 +0,0 @@
---
id: TASK-2
title: 'Grant sqs:SendMessage IAM on combiner queue to assessment-model runtime'
status: To Do
assignee:
- Jun-te Kim
created_date: '2026-04-18 19:01'
labels:
- infra
- iam
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Combine route sends to bulk-address2uprn-combiner-queue-<stage>. Runtime role needs sqs:SendMessage + sqs:GetQueueUrl on that queue ARN.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 IAM policy updated in terraform for staging + prod
- [ ] #2 Verified via AWS console or 'aws sqs get-queue-url' using runtime creds
<!-- AC:END -->

View file

@ -1,26 +0,0 @@
---
id: TASK-3
title: Deploy bulk_address2uprn_combiner Lambda + queue via terraform to staging/prod
status: To Do
assignee:
- Jun-te Kim
created_date: '2026-04-18 19:01'
labels:
- infra
- terraform
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Lambda source at /workspaces/home/github/Model/backend/bulk_address2uprn_combiner/. Uses lambda_with_sqs module. Needs S3_BUCKET_NAME=retrofit_sap_data_bucket_name and DB creds envs. Confirm queue name convention bulk-address2uprn-combiner-queue-<stage>.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Lambda + queue exist in staging
- [ ] #2 Lambda + queue exist in prod
- [ ] #3 Lambda has read on ara_raw_outputs/ and write on bulk_final_outputs/ in retrofit_sap_data bucket
<!-- AC:END -->

View file

@ -1,27 +1,36 @@
---
id: TASK-4
title: Smoke-test combiner end-to-end on dev
title: Smoke-test full bulk upload flow end-to-end on dev
status: To Do
assignee:
- Jun-te Kim
created_date: '2026-04-18 19:02'
updated_date: '2026-04-20'
labels:
- qa
- bulk-upload
dependencies: []
dependencies:
- TASK-6
- TASK-7
- TASK-8
- TASK-9
- TASK-10
priority: medium
ordinal: 9000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
After env var + IAM ready, run a real bulk upload -> map columns -> onboard -> wait for terminal complete. Confirm combiner fires.
After frontend + backend refactor ships, run the full flow on dev: upload xlsx → map columns → start onboarding → poll task progress → combiner fires → combined_output_s3_uri populated → review matches page renders → confirm rows → addresses appear in portfolio.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 POST /combine returns 200 with {taskId, subTaskId}
- [ ] #2 CloudWatch for bulk_address2uprn_combiner shows the subtask picked up
- [ ] #1 Frontend no longer needs POSTCODE_SPLITTER_QUEUE_NAME or BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME env vars
- [ ] #2 Backend logs show both splitter + combiner triggered via HTTP route
- [ ] #3 bulk_final_outputs/{task_id}/combined_<timestamp>.csv exists in retrofit_sap_data bucket
- [ ] #4 bulk_address_uploads.combined_output_s3_uri populated for the test upload
- [ ] #4 bulk_address_uploads.combined_output_s3_uri populated
- [ ] #5 Confirm-matches page renders with match rows
- [ ] #6 After confirm submit, addresses persist into portfolio
<!-- AC:END -->

View file

@ -0,0 +1,105 @@
---
id: TASK-6
title: Refactor onboard route to call backend trigger-splitter endpoint
status: In Progress
assignee: []
created_date: '2026-04-20'
updated_date: '2026-04-20 12:55'
labels:
- frontend
- bulk-upload
- refactor
dependencies:
- BACKEND-TASK-1
priority: high
ordinal: 1000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Currently `src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts` builds an SQS message and sends it to `POSTCODE_SPLITTER_QUEUE_NAME`. Move the SQS send to the backend. Frontend still transforms XLSX → CSV + uploads to S3, then calls backend HTTP `POST /v1/bulk-uploads/trigger-splitter`. Drop `POSTCODE_SPLITTER_QUEUE_NAME`, `sendToQueue`, and SQS IAM dependency.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `onboard/route.ts` no longer imports `sendToQueue` or reads `POSTCODE_SPLITTER_QUEUE_NAME`
- [x] #2 Transformed CSV still uploaded to `bulk_onboarding_inputs/{portfolioId}/{uploadId}.csv`
- [x] #3 Backend trigger-splitter endpoint called with correct payload
- [ ] #4 DB updates (bulk_address_uploads.status="processing", tasks.status, subTasks.inputs) still happen on success
- [ ] #5 4xx/5xx from backend → return 502 to client with useful message
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
<!-- SECTION:NOTES:BEGIN -->
### File changed
`src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/onboard/route.ts`
### What stays the same
- Auth check via `getServerSession`
- Upload record + status validation
- `transformFile()` XLSX → CSV
- S3 upload of transformed CSV to `bulk_onboarding_inputs/{portfolioId}/{uploadId}.csv`
- DB writes: `bulkAddressUploads.status = "processing"`, `tasks.status = "in progress"`, `subTasks.inputs`
### What changes
**Remove:**
```ts
import { sendToQueue } from "@/app/utils/sqs";
// POSTCODE_SPLITTER_QUEUE_NAME env var check
```
**Add — call backend trigger-splitter:**
```ts
const fastapiUrl = process.env.FASTAPI_API_URL;
const fastapiKey = process.env.FASTAPI_API_KEY;
if (!fastapiUrl || !fastapiKey) {
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
const sessionToken =
request.cookies.get("__Secure-next-auth.session-token")?.value ??
request.cookies.get("next-auth.session-token")?.value;
const triggerRes = await fetch(`${fastapiUrl}/v1/bulk-uploads/trigger-splitter`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": fastapiKey,
Authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify({
task_id: body.taskId,
sub_task_id: body.subTaskId,
s3_uri: s3Uri,
}),
});
if (!triggerRes.ok) {
const errText = await triggerRes.text().catch(() => "");
console.error("Backend trigger-splitter failed:", triggerRes.status, errText);
return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 });
}
```
**Order of ops** (no change to structure, just swap):
1. Validate body + upload record
2. Read source file from S3
3. Transform XLSX → CSV
4. Upload transformed CSV to S3
5. ~~sendToQueue~~ → fetch backend trigger-splitter
6. DB updates (status, taskId, subTask inputs) — only after step 5 succeeds
### Env vars required
- `FASTAPI_API_URL` — already set, already used in `plan/trigger/route.ts`
- `FASTAPI_API_KEY` — already set
### Env vars removed
- `POSTCODE_SPLITTER_QUEUE_NAME` — no longer read by frontend (can remove from .env.local + staging/prod)
<!-- SECTION:NOTES:END -->
<!-- SECTION:PLAN:END -->

View file

@ -0,0 +1,32 @@
---
id: TASK-7
title: Refactor combine route to call backend trigger-combiner endpoint
status: To Do
assignee: []
created_date: '2026-04-20'
updated_date: '2026-04-20'
labels:
- frontend
- bulk-upload
- refactor
dependencies:
- BACKEND-TASK-2
priority: high
ordinal: 2000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`src/app/api/portfolio/[portfolioId]/bulk-uploads/[uploadId]/combine/route.ts` sends SQS directly to `BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME`. Replace with call to backend `POST /bulk-uploads/{task_id}/combine`. Drop queue name env var + SendMessage IAM dependency on frontend.
If backend auto-chains combiner on splitter completion (backend-task-5), this route may simply proxy a manual "re-combine" action or be removed entirely. Decide during implementation.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 `combine/route.ts` no longer imports `sendToQueue` or reads `BULK_ADDRESS2UPRN_COMBINER_QUEUE_NAME`
- [ ] #2 Backend trigger-combiner endpoint called with task_id
- [ ] #3 Frontend still updates subTasks row with inputs on success (or delegates to backend)
- [ ] #4 Decision logged: proxy vs delete-after-auto-chain
<!-- AC:END -->

View file

@ -0,0 +1,34 @@
---
id: TASK-8
title: Add confirm-matches page for bulk upload address→UPRN review
status: To Do
assignee: []
created_date: '2026-04-20'
updated_date: '2026-04-20'
labels:
- frontend
- bulk-upload
- ui
dependencies:
- TASK-9
- BACKEND-TASK-3
priority: high
ordinal: 3000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
New route `/portfolio/{slug}/bulk-upload/{uploadId}/confirm-matches`. Loads combined CSV from backend (via frontend proxy route, see task-9) and renders review table: original address input | matched UPRN | matched address | confidence. User can accept/reject per row, then POST confirmed rows to backend to persist into portfolio `addresses`.
Status transitions: `complete` (combiner done) → show "Review matches" CTA on upload detail page → confirm-matches page → on submit, move upload to `confirmed` or similar.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Page renders match rows in a scrollable table
- [ ] #2 Row actions: accept (default on), reject
- [ ] #3 Submit posts accepted rows to backend confirm-matches route
- [ ] #4 After submit, redirect to portfolio addresses list
- [ ] #5 No useEffect/useMemo (per CLAUDE.md) — use Server Components + Route Handlers where possible
<!-- AC:END -->

View file

@ -0,0 +1,35 @@
---
id: TASK-9
title: Add proxy API routes for combined-results and confirm-matches
status: To Do
assignee: []
created_date: '2026-04-20'
updated_date: '2026-04-20'
labels:
- frontend
- bulk-upload
- api
dependencies:
- BACKEND-TASK-3
- BACKEND-TASK-4
priority: high
ordinal: 4000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Two Next.js route handlers that proxy to backend:
- `GET /api/portfolio/{portfolioId}/bulk-uploads/{uploadId}/combined-results` → backend GET `/bulk-uploads/{uploadId}/combined-results`. Returns parsed match rows for the confirm UI.
- `POST /api/portfolio/{portfolioId}/bulk-uploads/{uploadId}/confirm-matches` → backend POST `/bulk-uploads/{uploadId}/confirm-matches`. Body: accepted rows.
Both must: check session, pass through portfolio scope, translate backend errors to sane frontend responses.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Both routes auth-gated via getServerSession
- [ ] #2 Params typed as Promise per Next.js 15 convention
- [ ] #3 Backend 4xx/5xx surfaced with appropriate HTTP code + message
- [ ] #4 Upload id is validated against bulk_address_uploads row before proxying
<!-- AC:END -->

1
run_backlog_browser.sh Normal file
View file

@ -0,0 +1 @@
backlog browser

View file

@ -0,0 +1,67 @@
import { db } from "@/app/db/db";
import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads";
import { eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> }
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { uploadId } = await params;
const [upload] = await db
.select({ taskId: bulkAddressUploads.taskId })
.from(bulkAddressUploads)
.where(eq(bulkAddressUploads.id, uploadId))
.limit(1);
if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!upload.taskId) return NextResponse.json({ error: "Task not started" }, { status: 409 });
const fastapiUrl = process.env.FASTAPI_API_URL;
const fastapiKey = process.env.FASTAPI_API_KEY;
if (!fastapiUrl || !fastapiKey) {
console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set");
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
const sessionToken =
request.cookies.get("__Secure-next-auth.session-token")?.value ??
request.cookies.get("next-auth.session-token")?.value;
const { searchParams } = new URL(request.url);
const offset = searchParams.get("offset") ?? "0";
const limit = searchParams.get("limit") ?? "500";
try {
const res = await fetch(
`${fastapiUrl}/v1/bulk-uploads/${upload.taskId}/combined-results?offset=${offset}&limit=${limit}`,
{
headers: {
"x-api-key": fastapiKey,
Authorization: `Bearer ${sessionToken}`,
},
}
);
if (!res.ok) {
const errText = await res.text().catch(() => "");
console.error("Backend combined-results failed:", res.status, errText);
return NextResponse.json(
{ error: res.status === 409 ? "Combiner not finished" : "Failed to fetch results" },
{ status: res.status === 409 ? 409 : 502 }
);
}
const data = await res.json();
return NextResponse.json(data, { status: 200 });
} catch (err) {
console.error("Failed to reach backend combined-results:", err);
return NextResponse.json({ error: "Failed to fetch results" }, { status: 502 });
}
}

View file

@ -0,0 +1,70 @@
import { db } from "@/app/db/db";
import { bulkAddressUploads } from "@/app/db/schema/bulk_address_uploads";
import { eq } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ portfolioId: string; uploadId: string }> }
) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { uploadId } = await params;
const [upload] = await db
.select({ taskId: bulkAddressUploads.taskId })
.from(bulkAddressUploads)
.where(eq(bulkAddressUploads.id, uploadId))
.limit(1);
if (!upload) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!upload.taskId) return NextResponse.json({ error: "Task not started" }, { status: 409 });
const fastapiUrl = process.env.FASTAPI_API_URL;
const fastapiKey = process.env.FASTAPI_API_KEY;
if (!fastapiUrl || !fastapiKey) {
console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set");
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
const sessionToken =
request.cookies.get("__Secure-next-auth.session-token")?.value ??
request.cookies.get("next-auth.session-token")?.value;
let body;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
try {
const res = await fetch(
`${fastapiUrl}/v1/bulk-uploads/${upload.taskId}/confirm-matches`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": fastapiKey,
Authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify(body),
}
);
if (!res.ok) {
const errText = await res.text().catch(() => "");
console.error("Backend confirm-matches failed:", res.status, errText);
return NextResponse.json({ error: "Failed to confirm matches" }, { status: 502 });
}
const data = await res.json();
return NextResponse.json(data, { status: 200 });
} catch (err) {
console.error("Failed to reach backend confirm-matches:", err);
return NextResponse.json({ error: "Failed to confirm matches" }, { status: 502 });
}
}

View file

@ -8,7 +8,6 @@ import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
import { createS3Client } from "@/app/utils/s3";
import { sendToQueue } from "@/app/utils/sqs";
import S3 from "aws-sdk/clients/s3";
import * as XLSX from "xlsx";
@ -131,20 +130,41 @@ export async function POST(
}
const s3Uri = `s3://${outputBucket}/${transformedKey}`;
const queueName = process.env.POSTCODE_SPLITTER_QUEUE_NAME;
if (!queueName) {
console.error("POSTCODE_SPLITTER_QUEUE_NAME not set");
const fastapiUrl = process.env.FASTAPI_API_URL;
const fastapiKey = process.env.FASTAPI_API_KEY;
if (!fastapiUrl || !fastapiKey) {
console.error("FASTAPI_API_URL or FASTAPI_API_KEY not set");
return NextResponse.json({ error: "Server misconfiguration" }, { status: 500 });
}
const sessionToken =
request.cookies.get("__Secure-next-auth.session-token")?.value ??
request.cookies.get("next-auth.session-token")?.value;
try {
await sendToQueue(
{ task_id: body.taskId, sub_task_id: body.subTaskId, s3_uri: s3Uri },
{ queueName }
);
const triggerRes = await fetch(`${fastapiUrl}/v1/bulk-uploads/trigger-splitter`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": fastapiKey,
Authorization: `Bearer ${sessionToken}`,
},
body: JSON.stringify({
task_id: body.taskId,
sub_task_id: body.subTaskId,
s3_uri: s3Uri,
}),
});
if (!triggerRes.ok) {
const errText = await triggerRes.text().catch(() => "");
console.error("Backend trigger-splitter failed:", triggerRes.status, errText);
return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 });
}
} catch (err) {
console.error("Failed to send SQS message:", err);
return NextResponse.json({ error: "Failed to queue onboarding job" }, { status: 500 });
console.error("Failed to reach backend trigger-splitter:", err);
return NextResponse.json({ error: "Failed to trigger address matching" }, { status: 502 });
}
await Promise.all([