Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
691be9f
fix(ci): stop the Format gate policing a generated file
amielnoy Aug 26, 2026
ed2a6d3
fix: restore the pnpm pin dropped from package.json
amielnoy Aug 26, 2026
c336396
fix(deploy): turn off Vercel's own Git builds
amielnoy Aug 26, 2026
6156319
feat(content): the schema the Supabase migration was missing
amielnoy Aug 26, 2026
3a6e774
feat(scripts): one command to seed the academy content
amielnoy Aug 26, 2026
b804131
fix(scripts): name an unedited placeholder for what it is
amielnoy Aug 26, 2026
52e1e01
fix(deploy): stop answering /api with the site, and proxy it instead
amielnoy Aug 26, 2026
74ceb89
הוספת מדור מה כדאי לעשות עכשיו
amielnoy Aug 26, 2026
0b30786
fix(deploy): drop vercel.json — it was the wrong lever
amielnoy Aug 26, 2026
521a853
fix(ci): wait for the deployment before smoke-checking it
amielnoy Aug 26, 2026
5e2d0fb
fix(tests): wait for the stylesheet, not for a landmark that ships st…
amielnoy Aug 26, 2026
6e232a8
feat(content): the worked examples the decks fetch from an empty table
amielnoy Sep 1, 2026
98bc2e9
feat(db): one schema file for every table the API owns
amielnoy Sep 1, 2026
d5c665c
feat(auth): an account row, and the activity the counters were throwi…
amielnoy Sep 1, 2026
50c12ab
feat(progress): progress that follows the reader, not the browser
amielnoy Sep 1, 2026
787502f
feat(ci): test history that accumulates instead of overwriting itself
amielnoy Sep 1, 2026
bd82598
Merge pull request #26 from amielnoy/feat/supabase-durable-data
amielnoy Sep 1, 2026
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 .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Copy the ones you need into `.env.local`, which is git-ignored.
#
# This file is committed and `.env` is committed too — they hold public
# build-time configuration, nothing secret. A password in either is a password
# on its way to GitHub, so `scripts/src/seed-academy-content.ts` refuses to read
# one out of a tracked file.

# --- Seeding the academy content into Supabase -----------------------------
# `pnpm --filter @workspace/scripts run seed:academy`
#
# The password is the only thing not already in the repository: the host and
# user come from server/app/config.py. Supabase dashboard → Settings → Database.
SUPABASE_DB_PASSWORD=

# Overrides the composed connection entirely. Use the session pooler on 5432,
# not the transaction pooler on 6543 — the seed is one long transaction.
# DATABASE_URL=postgresql://postgres.<ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres

# Only if the project moves.
# SUPABASE_DB_HOST=
# SUPABASE_DB_USER=

# --- Deployed origin -------------------------------------------------------
# Read by the lecture-link builder and by the seed extractor, so the URLs in the
# database point where the decks actually live.
# VITE_SITE_ORIGIN=
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,12 +165,17 @@ jobs:
if: ${{ !cancelled() }}
run: ./run-all-tests.sh

- name: Publish test history to Prometheus
# Two destinations, both optional: the Pushgateway holds the latest run
# per branch for the dashboard, `test_runs` accumulates so a trend has
# something to be a trend across. A missing secret disables that half and
# says so; neither can fail the build.
- name: Publish test history
if: ${{ !cancelled() }}
env:
PUSHGATEWAY_URL: ${{ secrets.PUSHGATEWAY_URL }}
PUSHGATEWAY_USERNAME: ${{ secrets.PUSHGATEWAY_USERNAME }}
PUSHGATEWAY_PASSWORD: ${{ secrets.PUSHGATEWAY_PASSWORD }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: pnpm --filter @workspace/api-server run publish:test-metrics

- name: Link Grafana in the run summary
Expand Down
83 changes: 82 additions & 1 deletion .github/workflows/deploy-vercel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -188,12 +188,17 @@ jobs:
# is what `--prebuilt` uploads, so the routes in config.json are the ones
# that serve the deployment.
- name: Lay out the Vercel build output
env:
API_ORIGIN: ${{ vars.API_ORIGIN }}
run: |
set -euo pipefail
rm -rf .vercel/output
mkdir -p .vercel/output
cp -R _site .vercel/output/static
cp deploy/vercel/config.json .vercel/output/config.json
# The route table plus the one decision that cannot be committed:
# where /api/* goes. Unset, it answers 503 rather than letting the
# SPA catch-all hand an API call the academy's HTML shell at 200.
node deploy/vercel/build-config.mjs .vercel/output/config.json
echo "Static files: $(find .vercel/output/static -type f | wc -l)"

# `--prod` on main, a preview deployment everywhere else. The URL the CLI
Expand Down Expand Up @@ -239,6 +244,71 @@ jobs:
run: |
set -euo pipefail
fail=0

# `vercel deploy` prints the URL as soon as the deployment is created,
# which is before the edge is necessarily serving it — the first
# request can answer 404 against a deployment that is seconds from
# being live. That is exactly what reddened run 32950109049: the URL
# was printed at 08:55:32.801 and the check failed at 08:55:33.095,
# 294ms later, on a deployment that answers 302 to this day.
#
# So wait for the root to stop 404-ing before asserting anything. A
# public production deployment (200) and a protected preview (302 to
# the SSO endpoint) are both "ready"; only 404 — or a connection
# failure, which curl reports as 000 — means the edge has not caught
# up yet.
wait_for_deployment() {
local attempts=30 delay=2 attempt code=000
for attempt in $(seq 1 "$attempts"); do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 "${URL}/" || true)
case "${code:-000}" in
404|000) sleep "$delay" ;;
*) echo "Deployment reachable after ${attempt} attempt(s) (/ → $code)."; return 0 ;;
esac
done
# 000 is curl's "no response at all", which is a different failure
# from a deployment that exists and answers 404 — say which.
case "${code:-000}" in
000) echo "::error::${URL}/ never responded in $((attempts * delay))s — host unreachable." ;;
*) echo "::error::${URL}/ still returns $code after $((attempts * delay))s — the deployment never became reachable." ;;
esac
return 1
}
wait_for_deployment

# Preview deployments are protected (ssoProtection: preview), so every
# path answers with Vercel's login redirect and none of the route
# assertions below can run. Assert what is true there instead: the
# deployment exists and is protected. Production is public and gets
# the full pass.
if [ "${{ github.ref }}" != "refs/heads/main" ]; then
# One request, both facts. Two separate curls can land on different
# states and disagree about what this deployment did.
headers=$(curl -sSI --max-time 30 "${URL}/" | tr -d '\r')
code=$(printf '%s\n' "$headers" \
| awk 'toupper($1) ~ /^HTTP/ { print $2; exit }')
location=$(printf '%s\n' "$headers" \
| awk -F': ' 'tolower($1)=="location"{print $2}')
case "$code:$location" in
30?:https://vercel.com/sso-api*)
echo "✅ preview deployed and protected by Vercel Authentication"
exit 0 ;;
# Deployment Protection does not always answer with the redirect.
# A non-browser client — which `curl -I` is — gets a bare 401
# challenge in several configurations, and that is the protection
# working, not a broken deployment.
401:*)
echo "✅ preview deployed and protected (401 challenge)"
exit 0 ;;
200:*)
echo "::warning::This preview is public — expected Vercel Authentication."
;;
*)
echo "::error::Preview returned $code (location: ${location:-none})."
exit 1 ;;
esac
fi

check() { # path, expected status, what it proves
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 30 "${URL}$1")
if [ "$code" = "$2" ]; then
Expand Down Expand Up @@ -267,6 +337,17 @@ jobs:
esac
fi

# The failure this is here for is silent: an API path answered by the
# HTML shell at 200 makes the client conclude there is no server.
api_type=$(curl -sSI --max-time 30 "${URL}/api/ai/config" | tr -d '\r' \
| awk -F': ' 'tolower($1)=="content-type"{print $2}')
case "$api_type" in
*text/html*)
echo "::error::/api/ai/config is served as HTML — the SPA catch-all is answering it."
fail=1 ;;
*) echo "✅ /api/ai/config → ${api_type:-no content-type} (not the SPA shell)" ;;
esac

csp=$(curl -sSI --max-time 30 "${URL}/" | tr -d '\r' \
| awk -F': ' 'tolower($1)=="content-security-policy"{print $2}')
if [ -n "$csp" ]; then
Expand Down
7 changes: 7 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,10 @@ pnpm-lock.yaml

# Pasted material kept verbatim as a record of what was received.
attached_assets/

# Written by mockupPreviewPlugin.ts on every sandbox build. Formatting it is a
# fight nobody wins: Prettier rewrites it, the next build writes it back, and
# whoever built last commits the difference — which is exactly how it reached
# `main` and failed the Format gate there. It stays committed because a fresh
# checkout has to typecheck before anything has run the plugin.
artifacts/*/src/.generated/
Binary file added advice.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions artifacts/ai-testing-academy/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,15 @@ export function useAuth(): AuthContextValue {
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

/**
* The signed-in reader, for a consumer that works either way.
*
* `ProgressContext` is the case this exists for: it tracks progress for
* everyone and only syncs it for someone with an account, so being mounted
* without an `AuthProvider` — which is how most component tests mount it — is
* an ordinary state and not the programming error `useAuth` reports.
*/
export function useOptionalAuth(): AuthContextValue | null {
return useContext(AuthContext);
}
138 changes: 98 additions & 40 deletions artifacts/ai-testing-academy/src/context/ProgressContext.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { readValidated, writeValidated } from '../lib/storage';

export type ToolId = 'resume' | 'interview' | 'practice';

interface AcademyProgress {
resumeStarted: boolean;
resumeCompleted: boolean;
interviewStarted: boolean;
interviewAnswers: number;
interviewCompleted: boolean;
practiceCompleted: string[];
lecturesViewed: string[];
lastTool: ToolId | null;
}
import { useOptionalAuth } from './AuthContext';
import {
fetchProgress,
pushProgress,
validateProgress,
MAX_PROGRESS_IDS,
type AcademyProgress,
type ToolId,
} from '../lib/progressApi';

export type { ToolId };

interface ProgressContextValue extends AcademyProgress {
startTool: (tool: ToolId) => void;
Expand All @@ -36,38 +34,47 @@ const EMPTY_PROGRESS: AcademyProgress = {
};

function loadProgress(): AcademyProgress {
// A stored value and a response body are the same kind of thing here — both
// arrive from outside and both land in the state the site renders from — so
// both go through the one validator.
return (
readValidated<AcademyProgress>(localStorage, STORAGE_KEY, validateProgress) ?? EMPTY_PROGRESS
);
}

/** Order-insensitive on the two set columns, because the server's union is. */
function sameProgress(a: AcademyProgress, b: AcademyProgress): boolean {
const sameIds = (left: string[], right: string[]) =>
left.length === right.length &&
[...left].sort().join('\u0000') === [...right].sort().join('\u0000');
return (
readValidated<AcademyProgress>(localStorage, STORAGE_KEY, raw => {
const parsed = (raw ?? {}) as Partial<AcademyProgress>;
return {
resumeStarted: parsed.resumeStarted === true,
resumeCompleted: parsed.resumeCompleted === true,
interviewStarted: parsed.interviewStarted === true,
interviewAnswers:
Number.isSafeInteger(parsed.interviewAnswers) && Number(parsed.interviewAnswers) >= 0
? Number(parsed.interviewAnswers)
: 0,
interviewCompleted: parsed.interviewCompleted === true,
practiceCompleted: Array.isArray(parsed.practiceCompleted)
? parsed.practiceCompleted
.filter((id): id is string => typeof id === 'string')
.slice(0, 500)
: [],
lecturesViewed: Array.isArray(parsed.lecturesViewed)
? parsed.lecturesViewed.filter((id): id is string => typeof id === 'string').slice(0, 500)
: [],
lastTool: ['resume', 'interview', 'practice'].includes(parsed.lastTool || '')
? (parsed.lastTool as ToolId)
: null,
};
}) ?? EMPTY_PROGRESS
a.resumeStarted === b.resumeStarted &&
a.resumeCompleted === b.resumeCompleted &&
a.interviewStarted === b.interviewStarted &&
a.interviewAnswers === b.interviewAnswers &&
a.interviewCompleted === b.interviewCompleted &&
a.lastTool === b.lastTool &&
sameIds(a.practiceCompleted, b.practiceCompleted) &&
sameIds(a.lecturesViewed, b.lecturesViewed)
);
}

/**
* How long a burst of local changes is allowed to settle before it is synced.
*
* Answering an interview question updates progress on every answer; without
* this, so does a request.
*/
const SYNC_DELAY_MS = 1_000;

const ProgressContext = createContext<ProgressContextValue | null>(null);

export function ProgressProvider({ children }: { children: React.ReactNode }) {
const [progress, setProgress] = useState<AcademyProgress>(loadProgress);
// Optional on purpose: progress is tracked for everyone and synced only for
// someone with an account, so a tree without an AuthProvider is a normal one.
const user = useOptionalAuth()?.user ?? null;
const signedIn = user !== null;

const update = useCallback((fn: (current: AcademyProgress) => AcademyProgress) => {
setProgress(current => {
Expand All @@ -77,6 +84,57 @@ export function ProgressProvider({ children }: { children: React.ReactNode }) {
});
}, []);

/**
* Take a copy that came back from the server, if it says anything new.
*
* The guard is what stops the sync below from running forever: adopting the
* server's answer sets state, setting state re-runs the effect, and the next
* push returns the same union. Comparing before adopting makes the second
* round a no-op instead of the next lap.
*/
const adopt = useCallback((remote: AcademyProgress | null) => {
if (!remote) return;
setProgress(current => {
if (sameProgress(current, remote)) return current;
writeValidated(localStorage, STORAGE_KEY, remote);
return remote;
});
}, []);

// Merge this device's copy into the stored one, and adopt the union. Runs on
// sign-in and after every local change that settles, and it is the only write
// path: the response is the merge, so one call both saves and refreshes.
useEffect(() => {
if (!signedIn) return;
const controller = new AbortController();
const timer = window.setTimeout(() => {
void pushProgress(progress, controller.signal).then(adopt);
}, SYNC_DELAY_MS);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [signedIn, progress, adopt]);

// The other half of "follows you between devices": progress made on a phone
// is already stored, and this is the moment the laptop is worth telling. A
// read rather than a merge, because nothing changed here while the tab was
// in the background.
useEffect(() => {
if (!signedIn) return;
const controller = new AbortController();
const refresh = () => {
if (document.visibilityState === 'visible') {
void fetchProgress(controller.signal).then(adopt);
}
};
document.addEventListener('visibilitychange', refresh);
return () => {
document.removeEventListener('visibilitychange', refresh);
controller.abort();
};
}, [signedIn, adopt]);

const startTool = useCallback(
(tool: ToolId) =>
update(current => ({
Expand Down Expand Up @@ -128,7 +186,7 @@ export function ProgressProvider({ children }: { children: React.ReactNode }) {
lastTool: 'practice',
practiceCompleted: current.practiceCompleted.includes(id)
? current.practiceCompleted
: [...current.practiceCompleted, id],
: [...current.practiceCompleted, id].slice(-MAX_PROGRESS_IDS),
})),
[update],
);
Expand All @@ -139,7 +197,7 @@ export function ProgressProvider({ children }: { children: React.ReactNode }) {
...current,
lecturesViewed: current.lecturesViewed.includes(id)
? current.lecturesViewed
: [...current.lecturesViewed, id],
: [...current.lecturesViewed, id].slice(-MAX_PROGRESS_IDS),
})),
[update],
);
Expand Down
Loading
Loading