added onboaridng journey flow for address2uprn

This commit is contained in:
Jun-te Kim 2026-04-17 13:49:33 +00:00
parent 33d1b42d91
commit 8fef7ab26e
6 changed files with 121 additions and 20 deletions

View file

@ -9,7 +9,7 @@
"appPort": ["3000:3000"], # For devcontainer shell
"mounts": [
// Optional, just makes getting from Downloads (local env) easier
// "source=${localEnv:HOME},target=/workspaces/home,type=bind"
"source=${localEnv:HOME},target=/workspaces/home,type=bind"
],
"customizations": {
"vscode": {

View file

@ -1,26 +1,40 @@
import { db } from "@/app/db/db";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { eq } from "drizzle-orm";
import { eq, count, sql } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
_request: NextRequest,
{ params }: { params: Promise<{ taskId: string }> }
) {
try {
const { taskId } = await params;
const taskSubTasks = await db
.select()
.from(subTasks)
.where(eq(subTasks.taskId, taskId))
.orderBy(subTasks.updatedAt);
const { taskId } = await params;
return NextResponse.json(taskSubTasks);
try {
const [row] = await db
.select({
id: tasks.id,
taskSource: tasks.taskSource,
status: tasks.status,
service: tasks.service,
jobStarted: tasks.jobStarted,
jobCompleted: tasks.jobCompleted,
updatedAt: tasks.updatedAt,
totalSubtasks: count(subTasks.id),
completedSubtasks: sql<number>`count(case when lower(${subTasks.status}) in ('completed', 'complete') then 1 end)::int`,
failedSubtasks: sql<number>`count(case when lower(${subTasks.status}) in ('failed', 'failure', 'error') then 1 end)::int`,
})
.from(tasks)
.leftJoin(subTasks, eq(subTasks.taskId, tasks.id))
.where(eq(tasks.id, taskId))
.groupBy(tasks.id)
.limit(1);
if (!row) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json(row);
} catch (error) {
console.error("Error fetching subtasks:", error);
return NextResponse.json(
{ error: "Failed to fetch subtasks" },
{ status: 500 }
);
console.error("Error fetching task:", error);
return NextResponse.json({ error: "Failed to fetch task" }, { status: 500 });
}
}

View file

@ -1,7 +1,61 @@
import { db } from "@/app/db/db";
import { tasks } from "@/app/db/schema/tasks/tasks";
import { subTasks } from "@/app/db/schema/tasks/subtask";
import { desc, count } from "drizzle-orm";
import { NextRequest, NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { AuthOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { z } from "zod";
const CreateTaskSchema = z.object({
taskSource: z.string().min(1),
service: z.string().optional(),
source: z.literal("portfolio_id").optional(),
sourceId: z.string().optional(),
inputs: z.record(z.unknown()).optional(),
});
export async function POST(request: NextRequest) {
const session = await getServerSession(AuthOptions);
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
let body;
try {
body = CreateTaskSchema.parse(await request.json());
} catch {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
try {
const now = new Date();
const [task] = await db
.insert(tasks)
.values({
taskSource: body.taskSource,
service: body.service,
source: body.source,
sourceId: body.sourceId,
status: "waiting",
jobStarted: now,
})
.returning();
const [subTask] = await db
.insert(subTasks)
.values({
taskId: task.id,
status: "waiting",
inputs: body.inputs ? JSON.stringify(body.inputs) : null,
})
.returning();
return NextResponse.json({ taskId: task.id, subTaskId: subTask.id }, { status: 201 });
} catch (error) {
console.error("Failed to create task:", error);
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
}
}
export async function GET(request: NextRequest) {
try {

View file

@ -1205,6 +1205,13 @@
"when": 1776361262258,
"tag": "0171_chunky_wallow",
"breakpoints": true
},
{
"idx": 172,
"version": "7",
"when": 1776900000000,
"tag": "0172_bulk_upload_task_id",
"breakpoints": true
}
]
}

View file

@ -11,6 +11,7 @@ export const bulkAddressUploads = pgTable("bulk_address_uploads", {
status: text("status").notNull().default("ready_for_processing"),
sourceHeaders: text("source_headers").array().notNull().default(sql`'{}'`),
columnMapping: jsonb("column_mapping").$type<Record<string, string>>(),
taskId: uuid("task_id"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()

View file

@ -15,6 +15,8 @@ import {
ExclamationCircleIcon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import StartOnboardingButton from "./StartOnboardingButton";
import OnboardingProgress from "./OnboardingProgress";
function formatDate(date: Date) {
return new Intl.DateTimeFormat("en-GB", {
@ -39,9 +41,9 @@ const STATUS_CONFIG = {
icon: CheckCircleIcon,
iconBg: "bg-blue-50",
iconColor: "text-blue-500",
title: "Queued for processing",
body: "Column mapping saved. Your file is queued and will be processed shortly.",
cta: false,
title: "Mapping complete",
body: "Column mapping saved. Start onboarding to begin matching your addresses to UPRNs.",
cta: true,
},
processing: {
icon: ArrowPathIcon,
@ -119,7 +121,8 @@ export default async function BulkUploadDetailPage(props: {
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900 mb-1">{config.title}</p>
<p className="text-sm text-gray-500 leading-relaxed">{config.body}</p>
{config.cta && (
{statusKey === "ready_for_processing" && (
<Link
href={`/portfolio/${slug}/bulk-upload/${uploadId}/map-columns`}
className="mt-4 inline-flex items-center gap-2 px-5 py-2 rounded-xl bg-gradient-to-br from-[#14163d] to-[#15173e] text-white text-sm font-bold hover:opacity-90 transition-opacity"
@ -128,6 +131,28 @@ export default async function BulkUploadDetailPage(props: {
<ArrowRightIcon className="h-4 w-4" />
</Link>
)}
{statusKey === "mapping_complete" && (
<div className="mt-4 flex flex-col gap-3">
<Link
href={`/portfolio/${slug}/bulk-upload/${uploadId}/map-columns`}
className="inline-flex items-center gap-1.5 text-sm text-gray-400 hover:text-gray-700 transition-colors"
>
Edit column mapping
<ArrowRightIcon className="h-3.5 w-3.5" />
</Link>
<StartOnboardingButton
portfolioId={upload.portfolioId}
uploadId={uploadId}
filename={upload.filename}
/>
</div>
)}
{(statusKey === "processing" || statusKey === "complete" || statusKey === "failed") &&
upload.taskId && (
<OnboardingProgress taskId={upload.taskId} portfolioSlug={slug} />
)}
</div>
</div>
</div>