Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
16 changes: 12 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_placeholder

# Supabase Postgres connection string — server-only. Copy it from the dashboard's Connect panel.
# Never prefix this with NEXT_PUBLIC.
DATABASE_URL=postgresql://postgres:[YOUR-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres
# Administrative connection used only by db:migrate — server-only.
DATABASE_ADMIN_URL=postgresql://postgres:[ADMIN-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres

# Optional only for narration and the full nightly run; server-only.
# Restricted application runtime credential — server-only. Provision its secret after migrations.
# Never prefix either database credential with NEXT_PUBLIC.
DATABASE_URL=postgresql://huddle_app:[RUNTIME-PASSWORD]@db.[YOUR-PROJECT-REF].supabase.co:5432/postgres

# Optional only for narration; server-only.
ANTHROPIC_API_KEY=

# Protected nightly and per-request refresh dispatch; server-only.
INTERNAL_REFRESH_SECRET=replace-with-a-random-secret
INTERNAL_REFRESH_SECRET_PREVIOUS=
INTERNAL_REFRESH_URL=http://127.0.0.1:3000/internal/refresh

NODE_ENV=development
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ jobs:
validate:
runs-on: ubuntu-latest
env:
DATABASE_URL: postgresql://huddle:huddle@localhost:5432/huddle
DATABASE_ADMIN_URL: postgresql://huddle:huddle@localhost:5432/huddle
DATABASE_URL: postgresql://huddle_app:huddle_app@localhost:5432/huddle
RUN_DB_TESTS: '1'
services:
postgres:
Expand All @@ -32,6 +33,7 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- run: psql "$DATABASE_ADMIN_URL" -c "CREATE ROLE huddle_app NOINHERIT LOGIN PASSWORD 'huddle_app'"
- run: npm run typecheck
- run: npm run lint
- run: npm run format:check
Expand Down
7 changes: 7 additions & 0 deletions apps/web/app/board/entry-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export function EntryCard({ row }: { row: BoardRow }) {
<td>{formatAdditionalCauses(row.additionalCauses)}</td>
<td>{row.diagnosis ?? '—'}</td>
<td className="opener">{row.opener || '—'}</td>
<td>
{row.triageEntryId ? (
<a href={`/board/evidence/${row.triageEntryId}`}>Open exact evidence</a>
) : (
'—'
)}
</td>
</tr>
);
}
Expand Down
25 changes: 25 additions & 0 deletions apps/web/app/board/evidence/[entry_id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { resolveGuideAccess } from '../../../../lib/guide-access';

export const dynamic = 'force-dynamic';

export default async function EvidencePage({ params }: { params: { entry_id: string } }) {
const access = await resolveGuideAccess();
if (!access) return <p>Sign in with an authorized demo guide account to access this evidence.</p>;

const { getExactEvidenceForGuide } = await import('@huddle/db');
const result = await getExactEvidenceForGuide(access, params.entry_id);
if (!result) return <p>Evidence was not found in this guide scope.</p>;

return (
<main>
<h1>Exact evidence</h1>
<p>
Board run {result.boardRunId}; finding fingerprint {result.findingFingerprint}.
</p>
<pre>{JSON.stringify(result.evidence, null, 2)}</pre>
<p>
<a href="/board">Back to board</a>
</p>
</main>
);
}
81 changes: 77 additions & 4 deletions apps/web/app/board/lib/triage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {
loadHandAuthoredFixtures,
type RootCause,
} from '@huddle/core';
import type { BoardView } from '@huddle/application';
import { items, skillPrereqs, skills } from '@huddle/core/seed';
import { allRules, rankBySeverity, runEngine } from '@huddle/signal-engine';
import { RULE_CONFIG } from '@huddle/signal-engine/config/thresholds.js';

export type BoardRow = {
/** Stable domain identity; never a display name alias. */
studentId: string;
triageEntryId?: string;
studentFirstName: string;
rank: number;
severity: number;
Expand Down Expand Up @@ -39,10 +41,30 @@ export type BoardRequestDependencies = {
getGuideScopeForAuthUser(authUserId: string): Promise<GuideScope | null>;
/** The sole request-time data read, after identity and scope resolve. */
getTriageBoardForGuide(scope: GuideScope, boardDate: string): Promise<PersistedBoardRow[]>;
getBoardViewForGuide?(scope: GuideScope, boardDate: string): Promise<BoardView>;
};

export type AuthenticatedBoardResult =
{ status: 'authorized'; rows: BoardRow[] } | { status: 'unavailable'; rows: [] };
| {
status: 'authorized';
rows: BoardRow[];
board?: {
kind: BoardView['kind'];
requestedBoardDate: string;
boardDate: string | null;
refreshState: BoardView['refresh']['state'];
};
}
| { status: 'unavailable'; rows: [] };

type BoardState = NonNullable<Extract<AuthenticatedBoardResult, { status: 'authorized' }>['board']>;

export function formatBoardStatus(board: BoardState): string {
if (board.kind === 'not-built') return `Board not built. Refresh state: ${board.refreshState}.`;
if (board.kind === 'stale')
return `Showing the last successful board from ${board.boardDate}; refresh for ${board.requestedBoardDate} is ${board.refreshState}.`;
return `Board for ${board.requestedBoardDate}. Refresh state: ${board.refreshState}.`;
}

/**
* Request-time board boundary. No caller may supply a guide id: an authenticated Supabase identity
Expand Down Expand Up @@ -70,14 +92,21 @@ export async function getBoardEntriesForAuthenticatedRequest(
const { resolveGuideAccess } = await import('../../../lib/guide-access');
const access = await resolveGuideAccess();
if (!access) return { status: 'unavailable', rows: [] };
const { getTriageBoardForGuide } = await import('@huddle/db/scoped.js');
const rows = await getTriageBoardForGuide(access, resolvedBoardDate);
return toBoardResult(rows);
const { getBoardViewForGuide } = await import('@huddle/db/scoped.js');
return toBoardViewResult(
await getBoardViewForGuide(access, resolvedBoardDate),
resolvedBoardDate
);
}
const authUserId = await dependencies.getVerifiedAuthUserId();
if (!authUserId) return { status: 'unavailable', rows: [] };
const scope = await dependencies.getGuideScopeForAuthUser(authUserId);
if (!scope) return { status: 'unavailable', rows: [] };
if (dependencies.getBoardViewForGuide)
return toBoardViewResult(
await dependencies.getBoardViewForGuide(scope, resolvedBoardDate),
resolvedBoardDate
);
return toBoardResult(await dependencies.getTriageBoardForGuide(scope, resolvedBoardDate));
}

Expand All @@ -92,6 +121,50 @@ function toBoardResult(rows: PersistedBoardRow[]): AuthenticatedBoardResult {
};
}

function toBoardViewResult(view: BoardView, requestedBoardDate: string): AuthenticatedBoardResult {
if (view.kind === 'not-built')
return {
status: 'authorized',
rows: [],
board: {
kind: view.kind,
requestedBoardDate,
boardDate: null,
refreshState: view.refresh.state,
},
};
return {
status: 'authorized',
rows: view.entries.map((entry) => ({
studentId: entry.student.id,
triageEntryId: entry.triageEntryId,
studentFirstName: entry.student.firstName,
rank: entry.rank,
severity: entry.severity,
cause: entry.cause,
skillName:
entry.scope.kind === 'skill'
? `${entry.scope.skill.code} — ${entry.scope.skill.name}`
: '—',
diagnosis: entry.diagnosis,
opener: entry.opener,
additionalCauses:
(
entry as typeof entry & {
additionalCauses?: Array<{ cause: RootCause; severity: number }>;
}
).additionalCauses ?? [],
since: view.completedAt.slice(0, 10),
})),
board: {
kind: view.kind,
requestedBoardDate: view.requestedBoardDate,
boardDate: view.boardDate,
refreshState: view.refresh.state,
},
};
}

/**
* Explicit synthetic test fixture only. It never runs in a browser request and has no database or
* auth fallback role; its purpose is to prove the deterministic engine survives without narration.
Expand Down
96 changes: 96 additions & 0 deletions apps/web/app/import/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use server';

import type { ImportPreview, ImportReceipt } from '@huddle/application';
import { resolveGuideAccess } from '../../lib/guide-access';
import { emitOperationalEvent } from '../../lib/operational-events';

export type ImportActionResult =
| { kind: 'validated'; preview: ImportPreview }
| { kind: 'committed'; receipt: ImportReceipt }
| { kind: 'error'; message: string };

function uploadedFile(formData: FormData): File | null {
const value = formData.get('file');
return value instanceof File && value.size > 0 ? value : null;
}
export async function validateImportAction(formData: FormData): Promise<ImportActionResult> {
const access = await resolveGuideAccess();
const file = uploadedFile(formData);
const idempotencyKey = formData.get('idempotencyKey');
if (!access || !file || typeof idempotencyKey !== 'string' || idempotencyKey.length === 0)
return { kind: 'error', message: 'The synthetic import could not be validated.' };
try {
const { importerFor, syntheticImportFile } = await import('../../lib/operations');
const importer = await importerFor(access);
return {
kind: 'validated',
preview: await importer.validate(access, await syntheticImportFile(file, idempotencyKey)),
};
} catch {
return { kind: 'error', message: 'The synthetic import could not be validated.' };
}
}
export async function requestManualRefreshAction(): Promise<{
kind: 'queued' | 'succeeded' | 'error';
message: string;
}> {
const access = await resolveGuideAccess();
if (!access) return { kind: 'error', message: 'The refresh could not be requested.' };
try {
const { chicagoBoardDate, compilerFor } = await import('../../lib/operations');
const compiler = compilerFor();
const requested = await compiler.request({
trigger: 'manual',
access,
boardDate: chicagoBoardDate(),
});
if (requested.state === 'succeeded')
return { kind: 'succeeded', message: 'The current board is already refreshed.' };
if (requested.state !== 'queued')
return { kind: 'error', message: 'The refresh could not be requested.' };
const result = await compiler.compile(
{ workerId: 'server-manual-refresh', capability: 'board-refresh', syntheticOnly: true },
requested.requestId
);
emitOperationalEvent({
event: 'refresh',
state: result.kind,
correlationId: requested.requestId,
});
return result.kind === 'succeeded'
? { kind: 'succeeded', message: 'Board refresh completed.' }
: { kind: 'error', message: 'The prior board was preserved because refresh failed.' };
} catch {
return { kind: 'error', message: 'The refresh could not be requested.' };
}
}
export async function commitImportAction(
validatedImportId: string,
formData: FormData
): Promise<ImportActionResult> {
const access = await resolveGuideAccess();
const file = uploadedFile(formData);
const idempotencyKey = formData.get('idempotencyKey');
if (!access || !file || typeof idempotencyKey !== 'string')
return { kind: 'error', message: 'The synthetic import could not be committed.' };
try {
const { importerFor, syntheticImportFile } = await import('../../lib/operations');
const importer = await importerFor(access);
const receipt = await importer.commit(
access,
validatedImportId,
await syntheticImportFile(file, idempotencyKey)
);
emitOperationalEvent({
event: 'import',
state: receipt.status,
correlationId: receipt.importId,
inputFingerprint: receipt.payloadDigest,
accepted: receipt.counts.accepted,
rejected: receipt.counts.rejected,
});
return { kind: 'committed', receipt };
} catch {
return { kind: 'error', message: 'The synthetic import could not be committed.' };
}
}
Loading
Loading