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
26 changes: 1 addition & 25 deletions .github/workflows/frontend-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,12 @@ name: Frontend CI

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: pages-${{ github.ref }}
group: frontend-ci-${{ github.ref }}
cancel-in-progress: true

jobs:
Expand All @@ -34,24 +31,3 @@ jobs:

- name: Build
run: npm run build

- name: Upload Pages artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-pages-artifact@v3
with:
path: dist

deploy:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DELETE FROM passport_activities
WHERE completed_at IS NULL;
199 changes: 181 additions & 18 deletions server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ const apiPaths = new Set([
'/admin/export',
'/admin/import',
'/auth/session',
'/activity-count',
'/activity-kids',
'/kids',
'/passport',
'/wheel-prizes',
Expand Down Expand Up @@ -337,23 +339,107 @@ function normalizeOptionalLastKnownKidId(value: unknown) {
return value.trim().toLowerCase();
}

function passportResponse(
passportActivitiesByKid: PassportActivitiesByKid,
kidId: string,
): PassportActivity[] {
return passportActivitiesByKid[kidId] ?? passportTemplate(passportActivitiesByKid);
type PassportApiResponse = {
activities: PassportActivity[];
wheelShotSummary: {
availableShots: number;
earnedShots: number;
usedShots: number;
};
};

const passportActivityIds = Array.from({ length: 16 }, (_, index) => index + 1);

function getPassportWheelShotSummary(snapshot: StoreData, kidId: string) {
const passportActivities = mergePassportTemplate(
snapshot.passportActivitiesByKid[kidId],
);
const completedActivities = passportActivities.filter(
(activity) => activity.completedAt,
).length;
const spinEligibleActivities = Math.min(
completedActivities,
Math.max(passportActivities.length - 1, 0),
);
const earnedShots = Math.floor(spinEligibleActivities / 4);
const usedShots = snapshot.prizeAwards.filter(
(award) => award.kidId === kidId && award.source === 'wheel',
).length;

return {
availableShots: Math.max(earnedShots - usedShots, 0),
earnedShots,
usedShots,
};
}

function passportTemplate(
passportActivitiesByKid: PassportActivitiesByKid,
function passportResponse(snapshot: StoreData, kidId: string): PassportApiResponse {
return {
activities: mergePassportTemplate(snapshot.passportActivitiesByKid[kidId]),
wheelShotSummary: getPassportWheelShotSummary(snapshot, kidId),
};
}

function passportTemplate(): PassportActivity[] {
return passportActivityIds.map((id) => ({ id }));
}

function mergePassportTemplate(
passportActivities: PassportActivity[] | undefined,
): PassportActivity[] {
return (
Object.values(passportActivitiesByKid)[0]?.map((activity) => ({
id: activity.id,
})) ?? []
const completedActivitiesById = Object.fromEntries(
(passportActivities ?? []).map((activity) => [activity.id, activity]),
);

return passportTemplate().map(
(activity) => completedActivitiesById[activity.id] ?? activity,
);
}

function getActivityCompletionSummary(snapshot: StoreData, activityId: number) {
const completedEntries = snapshot.kids
.map((kid) => {
const completedAt = snapshot.passportActivitiesByKid[kid.id]?.find(
(activity) => activity.id === activityId,
)?.completedAt;

return completedAt ? { completedAt, kid } : undefined;
})
.filter((entry): entry is { completedAt: string; kid: StoreData['kids'][number] } =>
Boolean(entry),
)
.sort(
(firstEntry, secondEntry) =>
new Date(secondEntry.completedAt).getTime() -
new Date(firstEntry.completedAt).getTime(),
);

return {
count: completedEntries.length,
kids: completedEntries.slice(0, 5).map((entry) => ({
completedAt: entry.completedAt,
kid: entry.kid,
})),
};
}

function getActivityCompletionCounts(snapshot: StoreData) {
const counts: Record<string, number> = {};

Object.values(snapshot.passportActivitiesByKid).forEach((passportActivities) => {
passportActivities.forEach((activity) => {
if (!activity.completedAt) {
return;
}

const activityId = String(activity.id);
counts[activityId] = (counts[activityId] ?? 0) + 1;
});
});

return counts;
}

function normalizePrizeKind(value: unknown) {
if (value === undefined) {
return undefined;
Expand Down Expand Up @@ -508,9 +594,23 @@ function parseAdminBackup(body: Record<string, unknown>): AdminBackup {
}

async function handleKids(request: ApiRequest, url: URL): Promise<ApiResponse> {
void url;

if (request.method === 'GET') {
const searchedKid = url.searchParams.get('kid')?.trim();

if (searchedKid) {
const snapshot = await readSnapshot();
const normalizedKidId = normalizeKidId(searchedKid, snapshot);
const kid = snapshot.kids.find(
(entry) => entry.id.toLowerCase() === normalizedKidId.toLowerCase(),
);

if (!kid) {
throw new HttpError(404, `Unknown kid: ${normalizedKidId}`);
}

return jsonResponse(request, 200, kid);
}

await requireMagicLink(request, url, [...staffRoles]);

const snapshot = await readSnapshot();
Expand Down Expand Up @@ -636,6 +736,40 @@ async function handleAuth(
return jsonResponse(request, 200, session);
}

async function handleActivityKids(
request: ApiRequest,
url: URL,
): Promise<ApiResponse> {
if (request.method !== 'GET') {
throw new HttpError(405, 'Method not allowed');
}

const session = await requireMagicLink(request, url, ['lead']);
const activityId = parsePositiveInteger(url.searchParams.get('activity'), 'activity');

if (session.activityId !== activityId) {
throw new HttpError(403, 'Lead magic link cannot view this activity');
}

return jsonResponse(
request,
200,
getActivityCompletionSummary(await readSnapshot(), activityId),
);
}

async function handleActivityCount(request: ApiRequest): Promise<ApiResponse> {
if (request.method !== 'GET') {
throw new HttpError(405, 'Method not allowed');
}

return jsonResponse(
request,
200,
getActivityCompletionCounts(await readSnapshot()),
);
}

async function handlePassport(
request: ApiRequest,
url: URL,
Expand All @@ -646,15 +780,32 @@ async function handlePassport(
}

if (request.method === 'GET') {
await requireMagicLink(request, url, [...staffRoles]);

const snapshot = await readSnapshot();
const batchKidIds = url.searchParams
.getAll('kids')
.flatMap((kidIds) => kidIds.split(','))
.map((kidId) => kidId.trim())
.filter(Boolean);

if (batchKidIds.length > 0) {
const passportsByKid = Object.fromEntries(
[...new Set(batchKidIds)]
.map((kidId) => normalizeKidId(kidId, snapshot))
.map((kidId) => [
kidId,
passportResponse(snapshot, kidId),
]),
);

return jsonResponse(request, 200, passportsByKid);
}

const kidId = normalizeKidId(url.searchParams.get('kid'), snapshot);

return jsonResponse(
request,
200,
passportResponse(snapshot.passportActivitiesByKid, kidId),
passportResponse(snapshot, kidId),
);
}

Expand All @@ -672,13 +823,17 @@ async function handlePassport(
const snapshot = await readSnapshot();
const kidId = normalizeKidId(url.searchParams.get('kid'), snapshot);

const passport = await completePassportActivity({
await completePassportActivity({
activityId,
completedAt: new Date().toISOString(),
kidId,
});

return jsonResponse(request, 200, passport);
return jsonResponse(
request,
200,
passportResponse(await readSnapshot(), kidId),
);
}

async function handleWheelPrizes(
Expand Down Expand Up @@ -843,6 +998,14 @@ export async function handleApiRequest(request: ApiRequest): Promise<ApiResponse
return await handlePassport(request, requestUrl, path);
}

if (path === '/activity-count') {
return await handleActivityCount(request);
}

if (path === '/activity-kids') {
return await handleActivityKids(request, requestUrl);
}

if (path === '/auth/session') {
return await handleAuth(request, requestUrl, path);
}
Expand Down
39 changes: 13 additions & 26 deletions server/db-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type SqlClient = {
const kidRegistrationRetryDelayMs = 250;
const maxKidRegistrationAttempts = 20;
const generatedIdLookahead = 1000;
const passportActivityIds = Array.from({ length: 16 }, (_, index) => index + 1);

function isPostgresUrl(value: string | undefined): value is string {
return value?.startsWith('postgres://') === true ||
Expand Down Expand Up @@ -222,11 +223,9 @@ function mapMagicToken(row: Row): MagicLinkTokenRecord {
}

function getPassportTemplate(passportActivitiesByKid: PassportActivitiesByKid) {
return (
Object.values(passportActivitiesByKid)[0]?.map((activity) => ({
id: activity.id,
})) ?? []
);
void passportActivitiesByKid;

return passportActivityIds.map((id) => ({ id }));
}

function buildPassports(kids: Kid[], passportRows: Row[]): PassportActivitiesByKid {
Expand Down Expand Up @@ -277,12 +276,14 @@ function passportQueries(
) {
return [
tx`DELETE FROM passport_activities WHERE kid_id = ${kidId}`,
...passport.map(
(activity) => tx`
INSERT INTO passport_activities (kid_id, activity_id, completed_at)
VALUES (${kidId}, ${activity.id}, ${activity.completedAt ?? null}::timestamptz)
`,
),
...passport
.filter((activity) => activity.completedAt)
.map(
(activity) => tx`
INSERT INTO passport_activities (kid_id, activity_id, completed_at)
VALUES (${kidId}, ${activity.id}, ${activity.completedAt}::timestamptz)
`,
),
];
}

Expand Down Expand Up @@ -338,6 +339,7 @@ export function createDbStore(sql: SqlClient = createSqlClient()): StoreAdapter
sql`
SELECT kid_id, activity_id, completed_at::text AS completed_at
FROM passport_activities
WHERE completed_at IS NOT NULL
ORDER BY kid_id, activity_id
`,
sql`
Expand Down Expand Up @@ -414,21 +416,6 @@ export function createDbStore(sql: SqlClient = createSqlClient()): StoreAdapter
WHERE lower(candidate.id) <> ${command.lastKnownKidId ?? ''}
ON CONFLICT DO NOTHING
RETURNING id, name, age, gender, language
),
template_kid AS (
SELECT kid_id
FROM passport_activities
ORDER BY kid_id
LIMIT 1
),
inserted_passport AS (
INSERT INTO passport_activities (kid_id, activity_id)
SELECT inserted_kid.id, passport_activities.activity_id
FROM inserted_kid
JOIN template_kid ON true
JOIN passport_activities
ON passport_activities.kid_id = template_kid.kid_id
ON CONFLICT DO NOTHING
)
SELECT id, name, age, gender, language
FROM inserted_kid
Expand Down
Loading
Loading