Skip to content
Draft
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ DNLA_API_BASE=https://backend.dnla.com
DNLA_AREA=ESK
# Shared secret for DNLA's completion webhook: /api/dnla/webhook?secret=<value>
DNLA_WEBHOOK_SECRET=
# TEMPORARY TEST ONLY: comma/space-separated pre-issued TANs. The server
# reserves each TAN once and opens https://next.dnla.com/?tan=<TAN> without
# calling tan/create. Store the real list only in .env.local / Secret Manager.
DNLA_TEST_TANS=
DNLA_TEST_START_BASE=https://next.dnla.com/
# Result-scale calibration — CONFIRM WITH DNLA (max raw value + polarity).
DNLA_SCALE_MAX=9
DNLA_SCALE_HIGHER_IS_BETTER=true
Expand Down
5 changes: 4 additions & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ DNLA_AREA="ESK"
# Shared secret that authenticates DNLA's completion webhook. Register the
# webhook URL as https://<your-domain>/api/dnla/webhook?secret=<this-value>
DNLA_WEBHOOK_SECRET=""
# TEMPORARY TEST ONLY. Keep the real pre-issued TAN list out of git.
DNLA_TEST_TANS=""
DNLA_TEST_START_BASE="https://next.dnla.com/"
# Result-scale calibration — CONFIRM WITH DNLA. Max raw `values` and polarity
# (set HIGHER_IS_BETTER=false if 1 = best). Wrong values only mis-scale, never crash.
DNLA_SCALE_MAX="9"
Expand All @@ -34,4 +37,4 @@ DNLA_SCALE_HIGHER_IS_BETTER="true"
# To upgrade to Google Cloud Chirp 3 HD voices, enable the Cloud Text-to-Speech
# API and set a key here; leave empty to keep the default Gemini voice.
GOOGLE_TTS_API_KEY=""
GOOGLE_TTS_VOICE="en-US-Chirp3-HD-Leda"
GOOGLE_TTS_VOICE="en-US-Chirp3-HD-Leda"
54 changes: 43 additions & 11 deletions app/api/dnla/start/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ import { NextRequest, NextResponse } from "next/server";
import { getPrincipal, unauthorized } from "@/lib/server-auth";
import { enforceRateLimit } from "@/lib/rate-limit";
import { createTan, isDnlaConfigured, DnlaError } from "@/lib/dnla-client";
import { createDnlaSession } from "@/lib/dnla-store";
import { claimPreIssuedDnlaSession, createDnlaSession } from "@/lib/dnla-store";
import { buildDnlaTestStartUrl, getDnlaTestTans } from "@/lib/dnla-test-tans";
import { logger } from "@/lib/logger";

export const runtime = "nodejs";

/**
* Start a DNLA assessment for the signed-in candidate.
*
* Server-side: creates a DNLA TAN (api_key never leaves the server), records the
* TAN↔candidate mapping in Firestore, and returns the DNLA-hosted `start_url`
* the browser should open. Completion is delivered later via /api/dnla/webhook.
* Server-side: either claims one configured pre-issued test TAN or creates a new
* TAN through the partner API. It records the TAN↔candidate mapping in Firestore
* and returns the DNLA-hosted URL the browser should open. Completion is
* delivered later via /api/dnla/webhook.
*/
export async function POST(req: NextRequest) {
const principal = await getPrincipal(req);
Expand All @@ -26,13 +28,6 @@ export async function POST(req: NextRequest) {
});
if (limited) return limited;

if (!isDnlaConfigured()) {
return NextResponse.json(
{ ok: false, error: "DNLA is not configured on this deployment yet." },
{ status: 503 }
);
}

let body: any = {};
try {
body = await req.json();
Expand All @@ -44,7 +39,43 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ ok: false, error: "candidateId is required." }, { status: 400 });
}

const testTans = getDnlaTestTans();
if (testTans.length === 0 && !isDnlaConfigured()) {
return NextResponse.json(
{ ok: false, error: "DNLA is not configured on this deployment yet." },
{ status: 503 }
);
}

try {
if (testTans.length > 0) {
const session = await claimPreIssuedDnlaSession({
candidates: testTans.map((tan) => ({ tan, startUrl: buildDnlaTestStartUrl(tan) })),
ownerUid: principal.uid,
candidateId,
});
if (!session) {
return NextResponse.json(
{
ok: false,
error: "All temporary DNLA test TANs have already been reserved.",
},
{ status: 409 }
);
}

logger.info("[dnla/start] pre-issued test TAN reserved", {
uid: principal.uid,
candidateId,
});
return NextResponse.json({
ok: true,
startUrl: session.startUrl,
tan: session.tan,
mode: "pre-issued-test",
});
}

const created = await createTan({
email: body?.email ? String(body.email).slice(0, 200) : principal.email,
firstname: body?.firstname ? String(body.firstname).slice(0, 120) : undefined,
Expand All @@ -63,6 +94,7 @@ export async function POST(req: NextRequest) {
ok: true,
startUrl: created.tan.start_url,
tan: created.tan.tan_nummer,
mode: "live",
});
} catch (e: any) {
const status = e instanceof DnlaError ? e.status : 502;
Expand Down
15 changes: 14 additions & 1 deletion app/api/dnla/status/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { getPrincipal, unauthorized } from "@/lib/server-auth";
import { getLatestDnlaForCandidate } from "@/lib/dnla-store";
import { isDnlaConfigured } from "@/lib/dnla-client";
import { isDnlaTestModeConfigured } from "@/lib/dnla-test-tans";

export const runtime = "nodejs";

Expand All @@ -19,10 +21,21 @@ export async function GET(req: NextRequest) {
}

const session = await getLatestDnlaForCandidate(principal.uid, candidateId);
if (!session) return NextResponse.json({ ok: true, status: "none" });
const testMode = isDnlaTestModeConfigured();
const available = testMode || isDnlaConfigured();
if (!session) {
return NextResponse.json({
ok: true,
status: "none",
available,
mode: testMode ? "pre-issued-test" : "live",
});
}

return NextResponse.json({
ok: true,
available: true,
mode: session.source === "pre-issued-test" ? "pre-issued-test" : "live",
status: session.status, // "pending" | "complete" | "error" | "none"
startUrl: session.startUrl,
finishedAt: session.finishedAt ?? null,
Expand Down
24 changes: 18 additions & 6 deletions app/student/[id]/dnla/dnla-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,8 @@ function DnlaLivePanel({
onStart: () => void;
onOpen: () => void;
}) {
const { phase, error, starting, startUrl } = live;
const { phase, error, starting, startUrl, mode } = live;
const isTestMode = mode === "pre-issued-test";
if (phase === "loading" || phase === "complete") return null;

if (phase === "pending") {
Expand Down Expand Up @@ -221,14 +222,22 @@ function DnlaLivePanel({
<Card className="mb-6 border-brand-200/70 bg-brand-50/40">
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<Eyebrow className="text-brand-500">DNLA behavioural assessment</Eyebrow>
<Eyebrow className="text-brand-500">
{isTestMode ? "Temporary DNLA test flow" : "DNLA behavioural assessment"}
</Eyebrow>
<Heading as="h2" className="mt-1 text-lg sm:text-xl">
{phase === "not-configured" ? "Assessment not available yet" : "Start your DNLA assessment"}
{phase === "not-configured"
? "Assessment not available yet"
: isTestMode
? "Start with a pre-issued test TAN"
: "Start your DNLA assessment"}
</Heading>
<p className="mt-2 max-w-xl text-sm leading-6 text-ink-600">
{phase === "not-configured"
? "The DNLA provider isn't configured on this deployment yet. Sample scores are shown below in the meantime."
: "DNLA is a licensed psychometric assessment (Germany). Starting opens the questionnaire in a new tab; your competency profile is scored and shown here when you finish."}
: isTestMode
? "This branch reserves one unused test TAN and opens DNLA's login screen in a new tab. Finish the questionnaire there; TalEdge will keep checking for the provider result."
: "DNLA is a licensed psychometric assessment (Germany). Starting opens the questionnaire in a new tab; your competency profile is scored and shown here when you finish."}
</p>
</div>
{phase !== "not-configured" && (
Expand All @@ -239,7 +248,7 @@ function DnlaLivePanel({
onClick={onStart}
disabled={starting}
>
{starting ? "Starting…" : "Start assessment"}
{starting ? "Starting…" : isTestMode ? "Start test assessment" : "Start assessment"}
</Button>
)}
</CardHeader>
Expand All @@ -257,7 +266,8 @@ export default function DnlaClient({ student }: { student: Student }) {
// activated (NEXT_PUBLIC_DNLA_ENABLED=true), show ONLY the clearly-labelled
// sample profile and hide the "Start assessment" CTA — otherwise a candidate
// clicks Start and dead-ends on a 503 (reads as "DNLA is broken").
const dnlaEnabled = process.env.NEXT_PUBLIC_DNLA_ENABLED === "true";
const dnlaEnabled =
process.env.NEXT_PUBLIC_DNLA_ENABLED === "true" || live.available;

// Shared by both tracks; keep navigation within the current namespace
// (/exam for competitive-exam aspirants, /student for placement candidates).
Expand Down Expand Up @@ -423,6 +433,8 @@ export default function DnlaClient({ student }: { student: Student }) {
<Badge tone="danger">Assessment error</Badge>
) : live.phase === "loading" ? (
<Badge tone="neutral">Checking status…</Badge>
) : live.mode === "pre-issued-test" && live.available ? (
<Badge tone="brand">Test TAN available</Badge>
) : (
<Badge tone="warn">Sample data · provider pending</Badge>
)}
Expand Down
9 changes: 8 additions & 1 deletion hooks/useDnlaLive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,8 @@ export function useDnlaLive(candidateId: string) {
const [startUrl, setStartUrl] = useState<string | null>(null);
const [error, setError] = useState("");
const [starting, setStarting] = useState(false);
const [available, setAvailable] = useState(false);
const [mode, setMode] = useState<"live" | "pre-issued-test">("live");

const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const aliveRef = useRef(true);
Expand All @@ -75,6 +77,8 @@ export function useDnlaLive(candidateId: string) {
const applyStatus = useCallback((d: any) => {
if (!aliveRef.current) return;
const status = String(d?.status || "none");
setAvailable(d?.available === true);
setMode(d?.mode === "pre-issued-test" ? "pre-issued-test" : "live");
setStartUrl(typeof d?.startUrl === "string" ? d.startUrl : null);
if (status === "complete") {
setData({
Expand Down Expand Up @@ -165,6 +169,7 @@ export function useDnlaLive(candidateId: string) {
const d = await r.json().catch(() => ({}));
if (r.status === 503) {
// Provider not configured on this deployment.
setAvailable(false);
setPhase("not-configured");
return null;
}
Expand All @@ -174,6 +179,8 @@ export function useDnlaLive(candidateId: string) {
return null;
}
setStartUrl(d.startUrl);
setAvailable(true);
setMode(d?.mode === "pre-issued-test" ? "pre-issued-test" : "live");
setPhase("pending");
return d.startUrl as string;
} catch (e: any) {
Expand All @@ -187,7 +194,7 @@ export function useDnlaLive(candidateId: string) {
[candidateId]
);

return { phase, data, startUrl, error, starting, start, refresh };
return { phase, data, startUrl, error, starting, available, mode, start, refresh };
}

export type DnlaLive = ReturnType<typeof useDnlaLive>;
Loading