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
26 changes: 26 additions & 0 deletions app/(pages)/admin-trade-fixture/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { AdminTradeFixture } from "@/components/admin-trade-fixture";

// Test-only rendering of the admin trade-lead pipeline — same gate as
// /admin-fixture (ADMIN_A11Y_FIXTURE is set only by the Playwright webServer
// config; normal deployments 404). It grants no access to real data: the
// workspace's API calls are intercepted by Playwright route mocks.
export const dynamic = "force-dynamic";

export const metadata: Metadata = {
title: "Admin trade fixture",
robots: { index: false, follow: false },
};

export default function AdminTradeFixturePage() {
if (process.env.ADMIN_A11Y_FIXTURE !== "1") {
notFound();
}

return (
<main id="main-content" tabIndex={-1} className="mx-auto max-w-300 px-6 pb-20 md:pb-30">
<AdminTradeFixture />
</main>
);
}
19 changes: 19 additions & 0 deletions app/(pages)/admin/trade/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import { AdminTradePage } from "@/components/admin-trade-page";

export const metadata: Metadata = {
title: "Trade Leads — Admin",
description: "Deep Dive Brewing Co admin trade-lead pipeline.",
robots: {
index: false,
follow: false,
},
};

export default function AdminTradeRoute() {
return (
<main id="main-content" tabIndex={-1} className="mx-auto max-w-300 px-6 pb-20 md:pb-30">
<AdminTradePage />
</main>
);
}
66 changes: 66 additions & 0 deletions app/api/admin/trade-leads/[id]/activities/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminActor } from "@/lib/admin-auth";
import { badRequestResponse, getBearerToken, unauthorizedResponse } from "@/lib/api-auth";
import { apiErrorResponse } from "@/lib/api-error";
import { getRequestId, logInfo } from "@/lib/log";
import {
addTradeLeadNote,
getTradeLead,
listTradeLeadActivities,
tradeLeadActorOf,
} from "@/lib/trade-leads-admin";
import {
serializeTradeLead,
serializeTradeLeadActivity,
validateNoteBody,
} from "@/lib/trade-leads-admin-common";

interface RouteParams {
params: Promise<{ id: string }>;
}

// Append-only internal note. The note body is lead PII-adjacent content:
// logged as lead id + action only.
export async function POST(req: NextRequest, { params }: RouteParams) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

let body: unknown;
try {
body = await req.json();
} catch {
return badRequestResponse("Invalid JSON body.");
}

try {
const actor = await requireAdminActor(idToken);
const { id } = await params;

const parsed = validateNoteBody(
(body as Record<string, unknown>)?.note
);
if (!parsed.ok) return badRequestResponse(parsed.error);

await addTradeLeadNote(id, parsed.note, tradeLeadActorOf(actor));
logInfo("trade_lead.note_added", { leadId: id, requestId });

const [lead, activities] = await Promise.all([
getTradeLead(id),
listTradeLeadActivities(id),
]);
return NextResponse.json({
ok: true,
lead: lead ? serializeTradeLead(lead.id, lead.data) : null,
activities: activities.map((a) =>
serializeTradeLeadActivity(a.id, a.data)
),
});
} catch (error) {
return apiErrorResponse(error, {
fallback: "Failed to add note.",
event: "trade_lead.note_failed",
context: { requestId },
});
}
}
113 changes: 113 additions & 0 deletions app/api/admin/trade-leads/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminActor } from "@/lib/admin-auth";
import { badRequestResponse, getBearerToken, unauthorizedResponse } from "@/lib/api-auth";
import { apiErrorResponse } from "@/lib/api-error";
import { getRequestId, logInfo } from "@/lib/log";
import {
applyLeadPatch,
getTradeLead,
listTradeLeadActivities,
resolveAssignee,
tradeLeadActorOf,
} from "@/lib/trade-leads-admin";
import {
parseLeadPatchBody,
serializeTradeLead,
serializeTradeLeadActivity,
type NormalizedLeadPatch,
} from "@/lib/trade-leads-admin-common";

interface RouteParams {
params: Promise<{ id: string }>;
}

async function leadDetail(id: string) {
const [lead, activities] = await Promise.all([
getTradeLead(id),
listTradeLeadActivities(id),
]);
if (!lead) return null;
return {
lead: serializeTradeLead(lead.id, lead.data),
activities: activities.map((a) => serializeTradeLeadActivity(a.id, a.data)),
};
}

export async function GET(req: NextRequest, { params }: RouteParams) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

try {
await requireAdminActor(idToken);
const { id } = await params;
const detail = await leadDetail(id);
if (!detail) {
return NextResponse.json(
{ ok: false, error: "Trade lead not found." },
{ status: 404 }
);
}
// Read-only: viewing a lead must not touch updatedAt/lastActivityAt —
// those timestamps drive the 24-month retention window.
return NextResponse.json({ ok: true, ...detail });
} catch (error) {
return apiErrorResponse(error, {
fallback: "Failed to load trade lead.",
event: "trade_lead.get_failed",
context: { requestId },
});
}
}

export async function PATCH(req: NextRequest, { params }: RouteParams) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

let body: unknown;
try {
body = await req.json();
} catch {
return badRequestResponse("Invalid JSON body.");
}

try {
const actor = await requireAdminActor(idToken);
const { id } = await params;

const parsed = parseLeadPatchBody(body);
if (!parsed.ok) return badRequestResponse(parsed.error);

const patch: NormalizedLeadPatch = {
status: parsed.patch.status,
nextFollowUpAt: parsed.patch.nextFollowUpAt,
outcome: parsed.patch.outcome,
};
// The uid is untrusted input: resolve it to a verified, active admin
// record so a forged uid/name pair can never reach the document.
if (parsed.patch.assignedToUid !== undefined) {
patch.assignee = parsed.patch.assignedToUid
? await resolveAssignee(parsed.patch.assignedToUid)
: null;
}

const result = await applyLeadPatch(id, patch, tradeLeadActorOf(actor));
if (result.changed) {
logInfo("trade_lead.updated", {
leadId: id,
actions: result.activityTypes.join(","),
requestId,
});
}

const detail = await leadDetail(id);
return NextResponse.json({ ok: true, ...detail });
} catch (error) {
return apiErrorResponse(error, {
fallback: "Failed to update trade lead.",
event: "trade_lead.update_failed",
context: { requestId },
});
}
}
83 changes: 83 additions & 0 deletions app/api/admin/trade-leads/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireAdminActor } from "@/lib/admin-auth";
import { getBearerToken, badRequestResponse, unauthorizedResponse } from "@/lib/api-auth";
import { apiErrorResponse } from "@/lib/api-error";
import { getRequestId, logInfo } from "@/lib/log";
import {
createManualTradeLead,
getTradeLead,
listAssignableAdmins,
listTradeLeads,
tradeLeadActorOf,
} from "@/lib/trade-leads-admin";
import {
parseManualLeadBody,
serializeTradeLead,
} from "@/lib/trade-leads-admin-common";

export async function GET(req: NextRequest) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

try {
await requireAdminActor(idToken);
const [leads, admins] = await Promise.all([
listTradeLeads(),
listAssignableAdmins(),
]);
return NextResponse.json({
ok: true,
leads: leads.map((lead) => serializeTradeLead(lead.id, lead.data)),
admins,
});
} catch (error) {
return apiErrorResponse(error, {
fallback: "Failed to load trade leads.",
event: "trade_leads.list_failed",
context: { requestId },
});
}
}

export async function POST(req: NextRequest) {
const requestId = getRequestId(req.headers);
const idToken = getBearerToken(req);
if (!idToken) return unauthorizedResponse();

let body: unknown;
try {
body = await req.json();
} catch {
return badRequestResponse("Invalid JSON body.");
}

try {
const actor = await requireAdminActor(idToken);
const parsed = parseManualLeadBody(body);
if (!parsed.ok) return badRequestResponse(parsed.error);

// Lead id and action name only — never the submitted PII (#150).
const leadId = await createManualTradeLead(
parsed.input,
tradeLeadActorOf(actor)
);
logInfo("trade_lead.created", {
leadId,
source: parsed.input.source,
requestId,
});

const created = await getTradeLead(leadId);
return NextResponse.json({
ok: true,
lead: created ? serializeTradeLead(created.id, created.data) : null,
});
} catch (error) {
return apiErrorResponse(error, {
fallback: "Failed to create trade lead.",
event: "trade_lead.create_failed",
context: { requestId },
});
}
}
1 change: 1 addition & 0 deletions app/robots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export default function robots(): MetadataRoute.Robots {
disallow: [
"/admin",
"/admin-fixture",
"/admin-trade-fixture",
"/carousel-fixture",
"/where-to-buy-fixture",
"/trade/login",
Expand Down
Loading
Loading