diff --git a/app/api/deliveries.py b/app/api/deliveries.py index 3d8aa67..1c9e3a7 100644 --- a/app/api/deliveries.py +++ b/app/api/deliveries.py @@ -50,6 +50,6 @@ async def replay_delivery( ) except DeliveryNotReplayableError: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Delivery is not replayable" + status_code=status.HTTP_409_CONFLICT, detail="Delivery is not replayable" ) return delivery diff --git a/app/database/queries/deliveries.py b/app/database/queries/deliveries.py index 54bf842..d7dde36 100644 --- a/app/database/queries/deliveries.py +++ b/app/database/queries/deliveries.py @@ -41,6 +41,7 @@ async def get_claimed_delivery_for_processing( .options( selectinload(Delivery.destination), selectinload(Delivery.submission).selectinload(Submission.form), + selectinload(Delivery.attempts), ) .with_for_update() ) diff --git a/app/schemas/deliveries.py b/app/schemas/deliveries.py index a0d2852..c249f9a 100644 --- a/app/schemas/deliveries.py +++ b/app/schemas/deliveries.py @@ -33,8 +33,18 @@ class DeliveryBase(BaseModel): model_config = ConfigDict(from_attributes=True) +class DestinationRead(BaseModel): + id: int + type: str + reference: str + + model_config = ConfigDict(from_attributes=True) + + class DeliveryDetailRead(DeliveryBase): submission_id: int failure_type: FailureType | None created_at: datetime attempts: list[DeliveryAttemptRead] + external_reference: str | None + destination: DestinationRead diff --git a/frontend/src/app/api/auth/[action]/route.ts b/frontend/src/app/api/auth/[action]/route.ts new file mode 100644 index 0000000..fd9b79e --- /dev/null +++ b/frontend/src/app/api/auth/[action]/route.ts @@ -0,0 +1,51 @@ +const allowedActions = new Set(["login", "register", "logout"]); + +function apiBaseUrl(): string { + const value = process.env.ACKVIA_API_BASE_URL; + + if (!value) throw new Error("ACKVIA_API_BASE_URL is not configured"); + return value.replace(/\/$/, ""); +} + +export async function POST( + request: Request, + { params }: { params: Promise<{ action: string }> }, +) { + const { action } = await params; + + if (!allowedActions.has(action)) { + return Response.json({ detail: "Auth action not found" }, { status: 404 }); + } + + try { + const body = action === "logout" ? undefined : await request.text(); + const session = request.headers.get("cookie"); + const backendResponse = await fetch(`${apiBaseUrl()}/api/auth/${action}`, { + method: "POST", + cache: "no-store", + body, + headers: { + Accept: "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + ...(session ? { Cookie: session } : {}), + }, + }); + const responseBody = await backendResponse.text(); + const response = new Response(responseBody || null, { + status: backendResponse.status, + headers: { + "Cache-Control": "no-store, max-age=0", + ...(responseBody ? { "Content-Type": backendResponse.headers.get("content-type") ?? "application/json" } : {}), + }, + }); + const setCookie = backendResponse.headers.get("set-cookie"); + + if (setCookie) response.headers.append("set-cookie", setCookie); + return response; + } catch { + return Response.json( + { detail: "Ackvia authentication is unavailable" }, + { status: 502, headers: { "Cache-Control": "no-store, max-age=0" } }, + ); + } +} diff --git a/frontend/src/app/clients/[clientId]/page.tsx b/frontend/src/app/clients/[clientId]/page.tsx index 6fa2f43..5ccbe12 100644 --- a/frontend/src/app/clients/[clientId]/page.tsx +++ b/frontend/src/app/clients/[clientId]/page.tsx @@ -1,2 +1,2 @@ -import { ClientDetailPage } from "@/features/dashboard/pages"; -export default async function Page({ params }: { params: Promise<{ clientId: string }> }) { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/clients/page.tsx b/frontend/src/app/clients/page.tsx index 732c489..5ccbe12 100644 --- a/frontend/src/app/clients/page.tsx +++ b/frontend/src/app/clients/page.tsx @@ -1,2 +1,2 @@ -import { ClientsPage } from "@/features/dashboard/pages"; -export default function Page() { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/deliveries/[deliveryId]/attempts/[attemptId]/page.tsx b/frontend/src/app/deliveries/[deliveryId]/attempts/[attemptId]/page.tsx index f2a966e..5ccbe12 100644 --- a/frontend/src/app/deliveries/[deliveryId]/attempts/[attemptId]/page.tsx +++ b/frontend/src/app/deliveries/[deliveryId]/attempts/[attemptId]/page.tsx @@ -1,2 +1,2 @@ -import { AttemptDetailPage } from "@/features/dashboard/pages"; -export default async function Page({ params }: { params: Promise<{ deliveryId: string; attemptId: string }> }) { const resolved = await params; return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/deliveries/[deliveryId]/page.tsx b/frontend/src/app/deliveries/[deliveryId]/page.tsx index d34d530..5ccbe12 100644 --- a/frontend/src/app/deliveries/[deliveryId]/page.tsx +++ b/frontend/src/app/deliveries/[deliveryId]/page.tsx @@ -1,2 +1,2 @@ -import { DeliveryDetailPage } from "@/features/dashboard/pages"; -export default async function Page({ params }: { params: Promise<{ deliveryId: string }> }) { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/deliveries/page.tsx b/frontend/src/app/deliveries/page.tsx index db1ec65..5ccbe12 100644 --- a/frontend/src/app/deliveries/page.tsx +++ b/frontend/src/app/deliveries/page.tsx @@ -1,2 +1,2 @@ -import { DeliveriesPage } from "@/features/dashboard/pages"; -export default function Page() { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 1efa6d0..bb924d8 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -59,6 +59,22 @@ summary { list-style: none; } .skip-link { position: fixed; top: -80px; left: 12px; z-index: 100; padding: 12px 20px; background: var(--accent); color: var(--base); } .skip-link:focus { top: 8px; } +.login-page { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at 50% 0, #16213a 0, transparent 42%), var(--base); } +.login-card { width: min(100%, 420px); display: grid; gap: 28px; padding: 32px; border: 1px solid var(--line); border-radius: 10px; background: var(--surface); box-shadow: 0 24px 80px #0008; } +.login-brand { display: flex; align-items: center; gap: 10px; } +.login-brand span { display: grid; place-items: center; width: 32px; height: 32px; border-radius: 7px; background: var(--accent); color: white; font-weight: 800; } +.login-brand strong { font-size: 15px; } +.login-card h1 { margin-top: 6px; font-size: 30px; letter-spacing: -.04em; } +.login-intro { margin-top: 8px; color: var(--soft); } +.login-card form { display: grid; gap: 16px; } +.login-card label { display: grid; gap: 7px; color: var(--soft); font-weight: 600; } +.login-card input { height: 44px; padding: 0 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--base); } +.login-card input:focus { border-color: var(--accent); } +.login-submit { height: 44px; border-radius: 6px; background: var(--accent); color: white; font-weight: 700; } +.login-error { color: #ff8c8c; } +.login-setup { color: var(--muted); font-size: 12px; } +.login-setup a { color: var(--accent); text-decoration: underline; } + @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; scroll-behavior: auto !important; } } diff --git a/frontend/src/app/incidents/[incidentId]/page.tsx b/frontend/src/app/incidents/[incidentId]/page.tsx index b29e6e9..5ccbe12 100644 --- a/frontend/src/app/incidents/[incidentId]/page.tsx +++ b/frontend/src/app/incidents/[incidentId]/page.tsx @@ -1,2 +1,2 @@ -import { IncidentDetailPage } from "@/features/dashboard/pages"; -export default async function Page({ params }: { params: Promise<{ incidentId: string }> }) { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/incidents/page.tsx b/frontend/src/app/incidents/page.tsx index ceb26cf..5ccbe12 100644 --- a/frontend/src/app/incidents/page.tsx +++ b/frontend/src/app/incidents/page.tsx @@ -1,2 +1,2 @@ -import { IncidentsPage } from "@/features/dashboard/pages"; -export default function Page() { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 46e01f9..fccf572 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -5,8 +5,8 @@ import "./globals.css"; import "./dashboard.css"; export const metadata: Metadata = { - title: "Overview · Ackvia", - description: "Portfolio lead reliability at a glance.", + title: "Evidence · Ackvia", + description: "Captured leads, delivery outcomes, and attempt history.", }; export default function RootLayout({ children }: LayoutProps<"/">) { diff --git a/frontend/src/app/leads/[leadId]/page.tsx b/frontend/src/app/leads/[leadId]/page.tsx index e7ec023..5ccbe12 100644 --- a/frontend/src/app/leads/[leadId]/page.tsx +++ b/frontend/src/app/leads/[leadId]/page.tsx @@ -1,2 +1,2 @@ -import { LeadDetailPage } from "@/features/dashboard/pages"; -export default async function Page({ params }: { params: Promise<{ leadId: string }> }) { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/leads/page.tsx b/frontend/src/app/leads/page.tsx index 662b71a..5ccbe12 100644 --- a/frontend/src/app/leads/page.tsx +++ b/frontend/src/app/leads/page.tsx @@ -1,2 +1,2 @@ -import { LeadsPage } from "@/features/dashboard/pages"; -export default function Page() { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/login/login-form.tsx b/frontend/src/app/login/login-form.tsx new file mode 100644 index 0000000..e1b71f4 --- /dev/null +++ b/frontend/src/app/login/login-form.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { useRouter } from "next/navigation"; + +export function LoginForm({ setupUrl }: { setupUrl: string }) { + const router = useRouter(); + const [pending, setPending] = useState(false); + const [error, setError] = useState(""); + + async function submit(event: FormEvent) { + event.preventDefault(); + setPending(true); + setError(""); + + const data = new FormData(event.currentTarget); + const response = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: data.get("email"), + password: data.get("password"), + }), + }); + + if (response.ok) { + router.replace("/"); + router.refresh(); + return; + } + + let message = "Sign in failed."; + try { + const body = await response.json(); + if (typeof body.detail === "string") message = body.detail; + } catch {} + + setError(message); + setPending(false); + } + + return
+
+
AVAckvia
+

LEAD EVIDENCE

Sign in

Investigate captured submissions, delivery outcomes, and retry history.

+
+ + + {error &&

{error}

} + +
+

Need an account or workspace? Open setup

+
+
; +} diff --git a/frontend/src/app/login/page.tsx b/frontend/src/app/login/page.tsx new file mode 100644 index 0000000..5c11535 --- /dev/null +++ b/frontend/src/app/login/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; +import { LoginForm } from "./login-form"; + +export default async function LoginPage() { + if ((await cookies()).has("ackvia_session")) redirect("/"); + + const setupUrl = process.env.ACKVIA_API_BASE_URL?.replace(/\/$/, "") ?? "#"; + return ; +} diff --git a/frontend/src/app/monitoring/page.tsx b/frontend/src/app/monitoring/page.tsx index 0d61001..5ccbe12 100644 --- a/frontend/src/app/monitoring/page.tsx +++ b/frontend/src/app/monitoring/page.tsx @@ -1,2 +1,2 @@ -import { MonitoringPage } from "@/features/dashboard/pages"; -export default function Page() { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/overview/page.tsx b/frontend/src/app/overview/page.tsx index 196d609..5f7f5ab 100644 --- a/frontend/src/app/overview/page.tsx +++ b/frontend/src/app/overview/page.tsx @@ -1,6 +1,5 @@ -import "../overview.css"; -import { Overview } from "@/features/overview/overview"; +import { redirect } from "next/navigation"; export default function OverviewPage() { - return ; + redirect("/"); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 2d0fb1d..bd08c08 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1,5 +1,12 @@ +import { Desk } from "@/features/desk/desk"; +import { loadDesk } from "@/features/desk/load"; import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; -export default function Page() { - redirect("/overview"); +export default async function Page({ searchParams }: PageProps<"/">) { + if (!(await cookies()).has("ackvia_session")) redirect("/login"); + + const snapshot = await loadDesk(await searchParams); + + return ; } diff --git a/frontend/src/app/reports/page.tsx b/frontend/src/app/reports/page.tsx index 362c244..5ccbe12 100644 --- a/frontend/src/app/reports/page.tsx +++ b/frontend/src/app/reports/page.tsx @@ -1,2 +1,2 @@ -import { ReportsPage } from "@/features/dashboard/pages"; -export default function Page() { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/settings/page.tsx b/frontend/src/app/settings/page.tsx index 79993db..5ccbe12 100644 --- a/frontend/src/app/settings/page.tsx +++ b/frontend/src/app/settings/page.tsx @@ -1,2 +1,2 @@ -import { SettingsPage } from "@/features/dashboard/pages"; -export default function Page() { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/sites/[siteId]/page.tsx b/frontend/src/app/sites/[siteId]/page.tsx index 342ed5a..5ccbe12 100644 --- a/frontend/src/app/sites/[siteId]/page.tsx +++ b/frontend/src/app/sites/[siteId]/page.tsx @@ -1,2 +1,2 @@ -import { SiteDetailPage } from "@/features/dashboard/pages"; -export default async function Page({ params }: { params: Promise<{ siteId: string }> }) { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/app/sites/page.tsx b/frontend/src/app/sites/page.tsx index e45188a..5ccbe12 100644 --- a/frontend/src/app/sites/page.tsx +++ b/frontend/src/app/sites/page.tsx @@ -1,2 +1,2 @@ -import { SitesPage } from "@/features/dashboard/pages"; -export default function Page() { return ; } +import { redirect } from "next/navigation"; +export default function Page() { redirect("/"); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 4161aaf..d5fee53 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,3 +1,6 @@ +import "server-only"; + +import { cookies } from "next/headers"; import type { ApiError, DeliveryDetail, @@ -99,12 +102,14 @@ async function readErrorDetail(response: Response): Promise(path: string, init?: RequestInit): Promise { + const session = (await cookies()).get("ackvia_session")?.value; const response = await fetch(`${getApiBaseUrl()}${path}`, { ...init, signal: init?.signal ?? AbortSignal.timeout(15000), cache: "no-store", headers: { Accept: "application/json", + ...(session ? { Cookie: `ackvia_session=${session}` } : {}), ...init?.headers, }, }); diff --git a/frontend/tests/desk.spec.ts b/frontend/tests/desk.spec.ts index cb913a3..4298f4f 100644 --- a/frontend/tests/desk.spec.ts +++ b/frontend/tests/desk.spec.ts @@ -5,8 +5,16 @@ const context = `/?form=${form}`; const control = "http://127.0.0.1:8101/__control"; const stateUrl = "http://127.0.0.1:8101/__state"; -test.beforeEach(async ({ request }) => { +test.beforeEach(async ({ request, context }) => { await request.post(control, { data: {} }); + await context.addCookies([{ + name: "ackvia_session", + value: "fixture-session", + domain: "127.0.0.1", + path: "/", + httpOnly: true, + sameSite: "Lax", + }]); }); async function open(page: Page, suffix = "") { @@ -24,6 +32,7 @@ test("opens a source-scoped investigation and enriches a bounded page", async ({ await expect(page.locator(".destination")).toHaveCount(2); const state = await (await request.get(stateUrl)).json(); expect(state.maxReads).toBeLessThanOrEqual(4); + expect(state.requests.every((entry: { cookie: string | null }) => entry.cookie === "ackvia_session=fixture-session")).toBe(true); expect(state.requests.filter((entry: { path: string }) => entry.path.endsWith("/submissions"))[0].query).toEqual({ limit: "21", offset: "0" }); expect(new Set(state.requests.filter((entry: { path: string }) => /^\/api\/submissions\//.test(entry.path)).map((entry: { path: string }) => entry.path)).size).toBe(20); }); @@ -66,6 +75,24 @@ test("existing deep links map to the workspace, preserving pagination", async ({ await expect(page.locator(".outcome-failed")).toBeVisible(); }); +test("unsupported mock portfolio routes redirect to real evidence", async ({ page }) => { + for (const path of [ + "/overview", + "/clients", + "/sites", + "/incidents", + "/monitoring", + "/reports", + "/settings", + "/leads", + "/deliveries", + ]) { + await page.goto(path); + await expect(page).toHaveURL(/^http:\/\/127\.0\.0\.1:3100\/(?:\?.*)?$/); + await expect(page.locator("main.desk-workspace")).toBeVisible(); + } +}); + test("paginates submissions with lookahead and browses every form page", async ({ page }) => { await open(page); await page.getByRole("button", { name: "Next submissions", exact: true }).click(); diff --git a/frontend/tests/fixture-api.mjs b/frontend/tests/fixture-api.mjs index 466bdb4..ef2e2e4 100644 --- a/frontend/tests/fixture-api.mjs +++ b/frontend/tests/fixture-api.mjs @@ -78,7 +78,7 @@ createServer(async (request, response) => { options.newSubmission = true; return send({ ok: true }); } - requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams), method: request.method }); + requests.push({ path: url.pathname, query: Object.fromEntries(url.searchParams), method: request.method, cookie: request.headers.cookie ?? null }); if (options.readDelay) await wait(options.readDelay); if (url.pathname === "/api/forms") { if (options.scenario === "forms-error") return send({ detail: "Fixture API unavailable" }, 503); diff --git a/tests/test_deliveries.py b/tests/test_deliveries.py index 1873816..714057f 100644 --- a/tests/test_deliveries.py +++ b/tests/test_deliveries.py @@ -1326,3 +1326,29 @@ async def fake_execute(*args, **kwargs): assert claimed.attempt_count == original_attempt_count assert called is False + + +async def test_get_delivery_returns_destination_and_external_reference( + client, + db: AsyncSession, +): + _, workspace = await create_authenticated_workspace(client, db) + delivery = await create_delivery( + db, + DeliveryStatus.SUCCEEDED, + workspace=workspace, + ) + delivery.external_reference = "telegram-message-123" + await db.commit() + + response = await client.get(f"/api/deliveries/{delivery.id}") + + assert response.status_code == 200 + + data = response.json() + assert data["external_reference"] == "telegram-message-123" + assert data["destination"] == { + "id": delivery.destination.id, + "type": "telegram", + "reference": "123456789", + } diff --git a/tests/test_replay.py b/tests/test_replay.py index cc08045..b565632 100644 --- a/tests/test_replay.py +++ b/tests/test_replay.py @@ -284,3 +284,20 @@ async def test_manual_replay_membership_in_other_workspace_does_not_grant_access delivery.id, user.id, ) + + +async def test_replay_existing_non_replayable_delivery_returns_409( + client, + db: AsyncSession, +): + _, workspace = await create_authenticated_workspace(client, db) + delivery = await create_delivery( + db, + DeliveryStatus.SUCCEEDED, + workspace=workspace, + ) + + response = await client.post(f"/api/deliveries/{delivery.id}/replay") + + assert response.status_code == 409 + assert response.json()["detail"] == "Delivery is not replayable"