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
2 changes: 1 addition & 1 deletion app/api/deliveries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions app/database/queries/deliveries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
)
Expand Down
10 changes: 10 additions & 0 deletions app/schemas/deliveries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
51 changes: 51 additions & 0 deletions frontend/src/app/api/auth/[action]/route.ts
Original file line number Diff line number Diff line change
@@ -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" } },
);
}
}
4 changes: 2 additions & 2 deletions frontend/src/app/clients/[clientId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { ClientDetailPage } from "@/features/dashboard/pages";
export default async function Page({ params }: { params: Promise<{ clientId: string }> }) { return <ClientDetailPage clientId={(await params).clientId} />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
4 changes: 2 additions & 2 deletions frontend/src/app/clients/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { ClientsPage } from "@/features/dashboard/pages";
export default function Page() { return <ClientsPage />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
Original file line number Diff line number Diff line change
@@ -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 <AttemptDetailPage deliveryId={resolved.deliveryId} attemptId={resolved.attemptId} />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
4 changes: 2 additions & 2 deletions frontend/src/app/deliveries/[deliveryId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { DeliveryDetailPage } from "@/features/dashboard/pages";
export default async function Page({ params }: { params: Promise<{ deliveryId: string }> }) { return <DeliveryDetailPage deliveryId={(await params).deliveryId} />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
4 changes: 2 additions & 2 deletions frontend/src/app/deliveries/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { DeliveriesPage } from "@/features/dashboard/pages";
export default function Page() { return <DeliveriesPage />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
16 changes: 16 additions & 0 deletions frontend/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
}
4 changes: 2 additions & 2 deletions frontend/src/app/incidents/[incidentId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { IncidentDetailPage } from "@/features/dashboard/pages";
export default async function Page({ params }: { params: Promise<{ incidentId: string }> }) { return <IncidentDetailPage incidentId={(await params).incidentId} />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
4 changes: 2 additions & 2 deletions frontend/src/app/incidents/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { IncidentsPage } from "@/features/dashboard/pages";
export default function Page() { return <IncidentsPage />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
4 changes: 2 additions & 2 deletions frontend/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<"/">) {
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/app/leads/[leadId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { LeadDetailPage } from "@/features/dashboard/pages";
export default async function Page({ params }: { params: Promise<{ leadId: string }> }) { return <LeadDetailPage leadId={(await params).leadId} />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
4 changes: 2 additions & 2 deletions frontend/src/app/leads/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { LeadsPage } from "@/features/dashboard/pages";
export default function Page() { return <LeadsPage />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
55 changes: 55 additions & 0 deletions frontend/src/app/login/login-form.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLFormElement>) {
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 <main className="login-page">
<section className="login-card">
<div className="login-brand"><span>AV</span><strong>Ackvia</strong></div>
<div><p className="eyebrow">LEAD EVIDENCE</p><h1>Sign in</h1><p className="login-intro">Investigate captured submissions, delivery outcomes, and retry history.</p></div>
<form onSubmit={submit}>
<label>Email<input name="email" type="email" autoComplete="email" required /></label>
<label>Password<input name="password" type="password" autoComplete="current-password" required /></label>
{error && <p className="login-error" role="alert">{error}</p>}
<button className="login-submit" type="submit" disabled={pending}>{pending ? "Signing in…" : "Sign in"}</button>
</form>
<p className="login-setup">Need an account or workspace? <a href={setupUrl}>Open setup</a></p>
</section>
</main>;
}
10 changes: 10 additions & 0 deletions frontend/src/app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <LoginForm setupUrl={setupUrl} />;
}
4 changes: 2 additions & 2 deletions frontend/src/app/monitoring/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { MonitoringPage } from "@/features/dashboard/pages";
export default function Page() { return <MonitoringPage />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
5 changes: 2 additions & 3 deletions frontend/src/app/overview/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import "../overview.css";
import { Overview } from "@/features/overview/overview";
import { redirect } from "next/navigation";

export default function OverviewPage() {
return <Overview />;
redirect("/");
}
11 changes: 9 additions & 2 deletions frontend/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <Desk snapshot={snapshot} />;
}
4 changes: 2 additions & 2 deletions frontend/src/app/reports/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { ReportsPage } from "@/features/dashboard/pages";
export default function Page() { return <ReportsPage />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
4 changes: 2 additions & 2 deletions frontend/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { SettingsPage } from "@/features/dashboard/pages";
export default function Page() { return <SettingsPage />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
4 changes: 2 additions & 2 deletions frontend/src/app/sites/[siteId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { SiteDetailPage } from "@/features/dashboard/pages";
export default async function Page({ params }: { params: Promise<{ siteId: string }> }) { return <SiteDetailPage siteId={(await params).siteId} />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
4 changes: 2 additions & 2 deletions frontend/src/app/sites/page.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { SitesPage } from "@/features/dashboard/pages";
export default function Page() { return <SitesPage />; }
import { redirect } from "next/navigation";
export default function Page() { redirect("/"); }
5 changes: 5 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import "server-only";

import { cookies } from "next/headers";
import type {
ApiError,
DeliveryDetail,
Expand Down Expand Up @@ -99,12 +102,14 @@ async function readErrorDetail(response: Response): Promise<ApiError["detail"] |
}

async function request<T>(path: string, init?: RequestInit): Promise<T> {
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,
},
});
Expand Down
29 changes: 28 additions & 1 deletion frontend/tests/desk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "") {
Expand All @@ -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);
});
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion frontend/tests/fixture-api.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions tests/test_deliveries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
Loading
Loading