From 5619e5d390f1d52208657bde9e2472f04e41cded Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Wed, 19 Aug 2026 19:13:31 +0200 Subject: [PATCH 1/6] feat(projects): restore nomination lifecycle Expose year award categories and ordered project nominations through the shared project contracts and Worker APIs. Validate zero-to-two distinct same-year categories, preserve nomination-free ideas, and batch nomination writes with project membership changes across create, update, and claim flows. Keep nomination sets immutable once effective voting opens while allowing unrelated permitted edits, and cover persistence, validation, claims, and voting-open behavior with focused tests. --- src/app/components/ProjectForm.tsx | 2 + src/shared/projects.ts | 5 +- src/worker/repositories/projects.ts | 121 +++++++++++++++- src/worker/services/project-input.ts | 27 +++- test/app/ProjectForm.test.tsx | 2 + test/app/routes.test.tsx | 1 + test/projects/projects.test.ts | 206 +++++++++++++++++++++++++++ test/video/video.test.ts | 1 + 8 files changed, 359 insertions(+), 6 deletions(-) diff --git a/src/app/components/ProjectForm.tsx b/src/app/components/ProjectForm.tsx index 6898f0f..01b792a 100644 --- a/src/app/components/ProjectForm.tsx +++ b/src/app/components/ProjectForm.tsx @@ -128,6 +128,7 @@ export function ProjectForm({ kind, groupId: kind === 'idea' ? null : groupId || null, memberIds: kind === 'idea' ? [] : memberIds, + nominationCategoryIds: kind === 'idea' ? [] : initial.nominationCategoryIds, needsHelp: kind === 'project' && needsHelp, helpDetails: kind === 'project' && needsHelp ? helpDetails || null : null, }); @@ -369,6 +370,7 @@ function initialValues(project: ProjectDetail | undefined, claim: boolean) { kind, groupId: project?.group?.id ?? '', memberIds: project?.members.map(({id}) => id) ?? [], + nominationCategoryIds: claim ? [] : (project?.nominationCategoryIds ?? []), needsHelp: project?.needsHelp ?? false, helpDetails: project?.helpDetails ?? '', }; diff --git a/src/shared/projects.ts b/src/shared/projects.ts index c288bdc..bb4e36c 100644 --- a/src/shared/projects.ts +++ b/src/shared/projects.ts @@ -1,4 +1,4 @@ -import type {AwardSummary} from './administration'; +import type {AwardCategorySummary, AwardSummary} from './administration'; import type {SessionUser} from './api'; export type ProjectKind = 'project' | 'idea'; @@ -51,6 +51,7 @@ export interface ProjectSummary { export interface ProjectDetail extends ProjectSummary { media: MediaSummary[]; + nominationCategoryIds: string[]; permissions: { canEdit: boolean; canDelete: boolean; @@ -82,6 +83,7 @@ export interface ProjectResponse { export interface ProjectOptionsResponse { users: ProjectMember[]; groups: GroupSummary[]; + categories: AwardCategorySummary[]; } export interface ProjectWriteRequest { @@ -92,6 +94,7 @@ export interface ProjectWriteRequest { kind: ProjectKind; groupId: string | null; memberIds: string[]; + nominationCategoryIds: string[]; needsHelp: boolean; helpDetails: string | null; } diff --git a/src/worker/repositories/projects.ts b/src/worker/repositories/projects.ts index d91ed97..17d4cca 100644 --- a/src/worker/repositories/projects.ts +++ b/src/worker/repositories/projects.ts @@ -1,3 +1,4 @@ +import type {AwardCategorySummary} from '../../shared/administration'; import type {SessionUser} from '../../shared/api'; import type { GroupSummary, @@ -60,6 +61,12 @@ interface MediaRow { created_at: string; } +interface AwardCategoryRow { + id: string; + year_id: string; + name: string; +} + export async function listYears(db: D1Database): Promise { const {results} = await db .prepare( @@ -134,7 +141,7 @@ export async function listGroups(db: D1Database, yearId: string) { } export async function listProjectOptions(db: D1Database, yearId: string) { - const [groups, users] = await Promise.all([ + const [groups, users, categories] = await Promise.all([ listGroups(db, yearId), db .prepare( @@ -142,8 +149,19 @@ export async function listProjectOptions(db: D1Database, yearId: string) { FROM users ORDER BY display_name COLLATE NOCASE, id`, ) .all>(), + db + .prepare( + `SELECT id, year_id, name FROM award_categories + WHERE year_id = ? ORDER BY name COLLATE NOCASE, id`, + ) + .bind(yearId) + .all(), ]); - return {groups, users: users.results.map(mapMember)}; + return { + groups, + users: users.results.map(mapMember), + categories: categories.results.map(mapAwardCategory), + }; } export async function listProjects( @@ -223,7 +241,7 @@ export async function getProject( if (!row) { throw new ServiceError('NOT_FOUND', 'Project not found', 404); } - const [members, mediaResult, year] = await Promise.all([ + const [members, mediaResult, nominationIds, year] = await Promise.all([ membersByProjectIds(db, [projectId]), db .prepare( @@ -232,6 +250,7 @@ export async function getProject( ) .bind(projectId) .all(), + nominationCategoryIds(db, projectId), getYear(db, row.year_id), ]); const projectMembers = members.get(projectId) ?? []; @@ -243,6 +262,7 @@ export async function getProject( return { ...project, media: mediaResult.results.map(mapMedia), + nominationCategoryIds: nominationIds, permissions: { canEdit: canWrite, canDelete: !year.submissionsClosed && (isAdmin || row.creator_id === user.id), @@ -289,6 +309,7 @@ export async function createProject( .prepare('INSERT INTO project_members (project_id, user_id) VALUES (?, ?)') .bind(id, memberId), ), + ...nominationInsertStatements(db, id, input.nominationCategoryIds), ]; await db.batch(statements); return getProject(db, id, user); @@ -312,6 +333,21 @@ export async function updateProject( ); } await assertOpenYearAndReferences(db, input, user.id); + const existingNominationCategoryIds = await nominationCategoryIds(db, projectId); + const nominationsChanged = !sameOrderedValues( + existingNominationCategoryIds, + input.nominationCategoryIds, + ); + if ( + effectiveYearFlags(existing.year_id, existing).votingEnabled && + nominationsChanged + ) { + throw new ServiceError( + 'CONFLICT', + 'Award nominations cannot change after voting has opened', + 409, + ); + } const memberIds = input.kind === 'project' ? unique(input.memberIds) : []; await assertUsersExist(db, memberIds); await db.batch([ @@ -335,6 +371,14 @@ export async function updateProject( .prepare('INSERT INTO project_members (project_id, user_id) VALUES (?, ?)') .bind(projectId, memberId), ), + ...(nominationsChanged + ? [ + db + .prepare('DELETE FROM project_nominations WHERE project_id = ?') + .bind(projectId), + ...nominationInsertStatements(db, projectId, input.nominationCategoryIds), + ] + : []), ]); return getProject(db, projectId, user); } @@ -414,6 +458,17 @@ export async function claimProject( ) .bind(projectId, memberId, projectId, claimMarker), ), + ...input.nominationCategoryIds.map((categoryId, index) => + db + .prepare( + `INSERT INTO project_nominations + (project_id, award_category_id, position) + SELECT ?, ?, ? WHERE EXISTS ( + SELECT 1 FROM projects WHERE id = ? AND kind = 'project' AND updated_at = ? + )`, + ) + .bind(projectId, categoryId, index + 1, projectId, claimMarker), + ), ]); if (!result[0].meta.changes) { throw new ServiceError('CONFLICT', 'This idea has already been claimed', 409); @@ -516,6 +571,7 @@ async function assertOpenYearAndReferences( ); } } + await assertNominationCategories(db, input.nominationCategoryIds, input.yearId); await assertUsersExist(db, unique([actorId, ...input.memberIds])); } @@ -642,6 +698,10 @@ function mapGroup(row: { }; } +function mapAwardCategory(row: AwardCategoryRow): AwardCategorySummary { + return {id: row.id, yearId: row.year_id, name: row.name}; +} + function mapMember(row: Omit): ProjectMember { return { id: row.id, @@ -667,3 +727,58 @@ function mapMedia(row: MediaRow): MediaSummary { function unique(values: string[]) { return [...new Set(values)]; } + +async function assertNominationCategories( + db: D1Database, + categoryIds: string[], + yearId: string, +) { + if (!categoryIds.length) return; + const placeholders = categoryIds.map(() => '?').join(','); + const result = await db + .prepare( + `SELECT COUNT(*) count FROM award_categories + WHERE year_id = ? AND id IN (${placeholders})`, + ) + .bind(yearId, ...categoryIds) + .first<{count: number}>(); + if (result?.count !== categoryIds.length) { + throw new ServiceError( + 'VALIDATION_FAILED', + 'One or more award categories do not belong to this year', + 400, + ); + } +} + +async function nominationCategoryIds(db: D1Database, projectId: string) { + const {results} = await db + .prepare( + `SELECT award_category_id FROM project_nominations + WHERE project_id = ? ORDER BY position`, + ) + .bind(projectId) + .all<{award_category_id: string}>(); + return results.map(({award_category_id}) => award_category_id); +} + +function nominationInsertStatements( + db: D1Database, + projectId: string, + categoryIds: string[], +) { + return categoryIds.map((categoryId, index) => + db + .prepare( + `INSERT INTO project_nominations + (project_id, award_category_id, position) VALUES (?, ?, ?)`, + ) + .bind(projectId, categoryId, index + 1), + ); +} + +function sameOrderedValues(left: string[], right: string[]) { + return ( + left.length === right.length && left.every((value, index) => value === right[index]) + ); +} diff --git a/src/worker/services/project-input.ts b/src/worker/services/project-input.ts index c6f402f..bebb66c 100644 --- a/src/worker/services/project-input.ts +++ b/src/worker/services/project-input.ts @@ -8,6 +8,7 @@ import type {GroupWriteRequest, ProjectWriteRequest} from '../../shared/projects import {ServiceError} from './errors'; const MAX_MEMBERS = 50; +const MAX_NOMINATIONS = 2; export function parseProjectWrite(value: JsonInput): ProjectWriteRequest { if (!isJsonObject(value)) { @@ -36,9 +37,30 @@ export function parseProjectWrite(value: JsonInput): ProjectWriteRequest { invalid(`A project may have at most ${MAX_MEMBERS} members`); } + if (!Array.isArray(value.nominationCategoryIds)) { + invalid('Award nominations must be an array'); + } + const nominationCategoryIds = value.nominationCategoryIds.map((id) => + requiredText(id, 'Award category', 128), + ); + if (new Set(nominationCategoryIds).size !== nominationCategoryIds.length) { + invalid('Award nominations must be distinct'); + } + if (nominationCategoryIds.length > MAX_NOMINATIONS) { + invalid(`A project may nominate at most ${MAX_NOMINATIONS} award categories`); + } + if (kind === 'idea') { - if (groupId || memberIds.length || needsHelp || helpDetails) { - invalid('Ideas cannot have a group, team, or help request until claimed'); + if ( + groupId || + memberIds.length || + nominationCategoryIds.length || + needsHelp || + helpDetails + ) { + invalid( + 'Ideas cannot have a group, team, award nominations, or help request until claimed', + ); } } else if (!groupId) { invalid('Projects must belong to a group'); @@ -52,6 +74,7 @@ export function parseProjectWrite(value: JsonInput): ProjectWriteRequest { kind, groupId: kind === 'idea' ? null : groupId, memberIds: kind === 'idea' ? [] : memberIds, + nominationCategoryIds: kind === 'idea' ? [] : nominationCategoryIds, needsHelp: kind === 'project' && needsHelp, helpDetails: kind === 'project' && needsHelp ? helpDetails : null, }; diff --git a/test/app/ProjectForm.test.tsx b/test/app/ProjectForm.test.tsx index 1dd06fe..17d54b8 100644 --- a/test/app/ProjectForm.test.tsx +++ b/test/app/ProjectForm.test.tsx @@ -200,6 +200,7 @@ function renderProjectForm({ json({ groups: [{id: 'group', yearId: '2026', name: 'Orbital', projectCount: 1}], users, + categories: [], }), ); const client = new QueryClient({defaultOptions: {queries: {retry: false}}}); @@ -258,6 +259,7 @@ const projectFixture: ProjectDetail = { members: [alice], mediaCount: 0, media: [], + nominationCategoryIds: [], permissions: { canEdit: true, canDelete: true, diff --git a/test/app/routes.test.tsx b/test/app/routes.test.tsx index dcb865c..abc50bc 100644 --- a/test/app/routes.test.tsx +++ b/test/app/routes.test.tsx @@ -1502,6 +1502,7 @@ const projectFixture: ProjectDetail = { ], mediaCount: 0, media: [], + nominationCategoryIds: [], permissions: { canEdit: true, canDelete: true, diff --git a/test/projects/projects.test.ts b/test/projects/projects.test.ts index e0e79f8..a9406fd 100644 --- a/test/projects/projects.test.ts +++ b/test/projects/projects.test.ts @@ -7,14 +7,22 @@ import {createSessionCookie} from '../auth/fixture'; const base = 'https://hackweek.test/api'; let suffix = 0; let yearId: string; +let priorYearId: string; let groupId: string; +let categoryId: string; +let secondCategoryId: string; +let priorYearCategoryId: string; let memberToken: string; let outsiderToken: string; beforeEach(async () => { suffix += 1; yearId = `project-year-${String(suffix).padStart(3, '0')}`; + priorYearId = `project-prior-year-${String(suffix).padStart(3, '0')}`; groupId = `group-${suffix}`; + categoryId = `category-${suffix}-delight`; + secondCategoryId = `category-${suffix}-craft`; + priorYearCategoryId = `category-${suffix}-prior`; memberToken = await createSessionCookie({ sub: `project-member-${suffix}`, email: `project-member-${suffix}@sentry.io`, @@ -30,11 +38,24 @@ beforeEach(async () => { .bind(`project-member-${suffix}`) .first<{id: string}>(); await env.DB.batch([ + env.DB.prepare('INSERT INTO years (id) VALUES (?)').bind(priorYearId), env.DB.prepare('INSERT INTO years (id) VALUES (?)').bind(yearId), env.DB.prepare( `INSERT INTO groups (id, source_id, year_id, name, creator_id) VALUES (?, ?, ?, ?, ?)`, ).bind(groupId, groupId, yearId, 'Orbital', user!.id), + env.DB.prepare( + `INSERT INTO award_categories (id, source_id, year_id, name, creator_id) + VALUES (?, ?, ?, ?, ?)`, + ).bind(categoryId, categoryId, yearId, 'Delight', user!.id), + env.DB.prepare( + `INSERT INTO award_categories (id, source_id, year_id, name, creator_id) + VALUES (?, ?, ?, ?, ?)`, + ).bind(secondCategoryId, secondCategoryId, yearId, 'Craft', user!.id), + env.DB.prepare( + `INSERT INTO award_categories (id, source_id, year_id, name, creator_id) + VALUES (?, ?, ?, ?, ?)`, + ).bind(priorYearCategoryId, priorYearCategoryId, priorYearId, 'Past award', user!.id), ]); }); @@ -74,6 +95,190 @@ describe('project and history APIs', () => { expect(page.body.projects[0].members).toBeInstanceOf(Array); }); + it('returns ordered year categories and persists ordered project nominations', async () => { + const options = await api(`/years/${yearId}/options`, memberToken); + const allCategories = await createProject(memberToken, { + name: 'All categories', + }); + const focused = await createProject(memberToken, { + name: 'Focused project', + nominationCategoryIds: [categoryId], + }); + const updated = await api(`/projects/${focused.id}`, memberToken, { + method: 'PUT', + body: { + ...projectPayload(), + name: 'Focused project', + nominationCategoryIds: [categoryId, secondCategoryId], + }, + }); + const detail = await api(`/projects/${allCategories.id}`, memberToken); + const page = await api(`/projects?year=${yearId}`, memberToken); + const stored = await env.DB.prepare( + `SELECT award_category_id, position FROM project_nominations + WHERE project_id = ? ORDER BY position`, + ) + .bind(focused.id) + .all<{award_category_id: string; position: number}>(); + const allCategoryNominationCount = await env.DB.prepare( + 'SELECT COUNT(*) count FROM project_nominations WHERE project_id = ?', + ) + .bind(allCategories.id) + .first<{count: number}>(); + + expect(options).toMatchObject({ + status: 200, + body: { + categories: [ + {id: secondCategoryId, yearId, name: 'Craft'}, + {id: categoryId, yearId, name: 'Delight'}, + ], + }, + }); + expect(detail.body.project.nominationCategoryIds).toEqual([]); + expect(allCategoryNominationCount?.count).toBe(0); + expect(updated.body.project.nominationCategoryIds).toEqual([ + categoryId, + secondCategoryId, + ]); + expect(stored.results).toEqual([ + {award_category_id: categoryId, position: 1}, + {award_category_id: secondCategoryId, position: 2}, + ]); + expect( + page.body.projects.find((project: {id: string}) => project.id === focused.id), + ).not.toHaveProperty('nominationCategoryIds'); + }); + + it('rejects invalid nomination sets without changing stored project data', async () => { + const project = await createProject(memberToken, { + name: 'Stable project', + nominationCategoryIds: [categoryId], + }); + const duplicate = await api(`/projects/${project.id}`, memberToken, { + method: 'PUT', + body: { + ...projectPayload(), + nominationCategoryIds: [categoryId, categoryId], + }, + }); + const oversized = await api(`/projects/${project.id}`, memberToken, { + method: 'PUT', + body: { + ...projectPayload(), + nominationCategoryIds: [categoryId, secondCategoryId, 'third'], + }, + }); + const missing = await api(`/projects/${project.id}`, memberToken, { + method: 'PUT', + body: {...projectPayload(), nominationCategoryIds: ['missing-category']}, + }); + const crossYear = await api(`/projects/${project.id}`, memberToken, { + method: 'PUT', + body: { + ...projectPayload(), + name: 'Must not be stored', + nominationCategoryIds: [priorYearCategoryId], + }, + }); + const malformed = await api('/projects', memberToken, { + method: 'POST', + body: {...projectPayload(), nominationCategoryIds: undefined}, + }); + const storedProject = await env.DB.prepare('SELECT name FROM projects WHERE id = ?') + .bind(project.id) + .first<{name: string}>(); + const nominations = await env.DB.prepare( + 'SELECT award_category_id FROM project_nominations WHERE project_id = ?', + ) + .bind(project.id) + .all<{award_category_id: string}>(); + + for (const response of [duplicate, oversized, missing, crossYear, malformed]) { + expect(response.status).toBe(400); + expect(response.body.error.code).toBe('VALIDATION_FAILED'); + } + expect(storedProject?.name).toBe('Stable project'); + expect(nominations.results).toEqual([{award_category_id: categoryId}]); + }); + + it('keeps ideas nomination-free and lets claims establish nominations', async () => { + const idea = await createProject(memberToken, { + name: 'Claim me', + kind: 'idea', + groupId: null, + }); + const rejectedIdea = await api('/projects', memberToken, { + method: 'POST', + body: { + ...projectPayload(), + kind: 'idea', + groupId: null, + nominationCategoryIds: [categoryId], + }, + }); + await session(outsiderToken); + const claimed = await api(`/projects/${idea.id}/claim`, outsiderToken, { + method: 'POST', + body: { + ...projectPayload(), + name: 'Claimed with focus', + nominationCategoryIds: [secondCategoryId, categoryId], + }, + }); + + expect(rejectedIdea).toMatchObject({ + status: 400, + body: {error: {code: 'VALIDATION_FAILED'}}, + }); + expect(claimed.status).toBe(200); + expect(claimed.body.project.nominationCategoryIds).toEqual([ + secondCategoryId, + categoryId, + ]); + }); + + it('freezes nomination changes while voting permits unrelated edits', async () => { + const project = await createProject(memberToken, { + name: 'Voting project', + nominationCategoryIds: [categoryId], + }); + await env.DB.prepare('UPDATE years SET voting_enabled = 1 WHERE id = ?') + .bind(yearId) + .run(); + + const unchanged = await api(`/projects/${project.id}`, memberToken, { + method: 'PUT', + body: { + ...projectPayload(), + name: 'Allowed rename', + nominationCategoryIds: [categoryId], + }, + }); + const changed = await api(`/projects/${project.id}`, memberToken, { + method: 'PUT', + body: { + ...projectPayload(), + name: 'Blocked rename', + nominationCategoryIds: [secondCategoryId], + }, + }); + const stored = await api(`/projects/${project.id}`, memberToken); + + expect(unchanged).toMatchObject({ + status: 200, + body: {project: {name: 'Allowed rename', nominationCategoryIds: [categoryId]}}, + }); + expect(changed).toMatchObject({ + status: 409, + body: {error: {code: 'CONFLICT'}}, + }); + expect(stored.body.project).toMatchObject({ + name: 'Allowed rename', + nominationCategoryIds: [categoryId], + }); + }); + it('exposes project voting permission for eligible viewers but not creators, members, or ideas', async () => { const project = await createProject(memberToken); const idea = await createProject(memberToken, {kind: 'idea', groupId: null}); @@ -376,6 +581,7 @@ function projectPayload(): ProjectWriteRequest { kind: 'project' as const, groupId, memberIds: [], + nominationCategoryIds: [], needsHelp: false, helpDetails: null, }; diff --git a/test/video/video.test.ts b/test/video/video.test.ts index 0254106..88f217a 100644 --- a/test/video/video.test.ts +++ b/test/video/video.test.ts @@ -961,6 +961,7 @@ function projectPayload(name: string): ProjectWriteRequest { kind: 'project', groupId, memberIds: [], + nominationCategoryIds: [], needsHelp: false, helpDetails: null, }; From 7fa5f2a81c88030c71f71db06212cfcbb913ce0e Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Wed, 19 Aug 2026 19:21:19 +0200 Subject: [PATCH 2/6] feat(voting): enforce project nomination eligibility Recreate the D1 vote insert and update triggers so projects with no nominations remain open to every category while restricted projects accept only their nominated categories. Map rejected vote writes to a validation error and expose nomination eligibility on each compact ballot selection. Preserve historical conflicting votes in place so they remain visible and movable, and cover eligible casts, atomic rejected casts and moves, withdrawn picks, migration safety, and the retired admin nomination API. --- .../0009_nomination_vote_eligibility.sql | 83 +++++++ src/shared/administration.ts | 1 + src/worker/repositories/administration.ts | 13 +- test/app/administration.test.tsx | 5 + test/migration/migration.test.ts | 101 +++++++++ test/voting/voting.test.ts | 207 ++++++++++++++++-- 6 files changed, 390 insertions(+), 20 deletions(-) create mode 100644 migrations/0009_nomination_vote_eligibility.sql diff --git a/migrations/0009_nomination_vote_eligibility.sql b/migrations/0009_nomination_vote_eligibility.sql new file mode 100644 index 0000000..a68e40a --- /dev/null +++ b/migrations/0009_nomination_vote_eligibility.sql @@ -0,0 +1,83 @@ +PRAGMA foreign_keys = ON; + +DROP TRIGGER IF EXISTS votes_validate_insert; +DROP TRIGGER IF EXISTS votes_validate_update; + +CREATE TRIGGER votes_validate_insert BEFORE INSERT ON votes +BEGIN + SELECT RAISE(ABORT, 'voting is not enabled for this year') + WHERE NOT EXISTS ( + SELECT 1 FROM years WHERE id = NEW.year_id AND voting_enabled = 1 + ); + SELECT RAISE(ABORT, 'vote project must be an active project in vote year') + WHERE NOT EXISTS ( + SELECT 1 FROM projects + WHERE id = NEW.project_id AND year_id = NEW.year_id + AND kind = 'project' AND status = 'active' + ); + SELECT RAISE(ABORT, 'vote category must belong to vote year') + WHERE NOT EXISTS ( + SELECT 1 FROM award_categories + WHERE id = NEW.award_category_id AND year_id = NEW.year_id + ); + SELECT RAISE(ABORT, 'vote project is not eligible for this award category') + WHERE EXISTS ( + SELECT 1 FROM project_nominations WHERE project_id = NEW.project_id + ) AND NOT EXISTS ( + SELECT 1 FROM project_nominations + WHERE project_id = NEW.project_id + AND award_category_id = NEW.award_category_id + ); + SELECT RAISE(ABORT, 'users cannot vote for their own project') + WHERE EXISTS ( + SELECT 1 FROM projects p + WHERE p.id = NEW.project_id + AND ( + p.creator_id = NEW.creator_id + OR EXISTS ( + SELECT 1 FROM project_members pm + WHERE pm.project_id = p.id AND pm.user_id = NEW.creator_id + ) + ) + ); +END; + +CREATE TRIGGER votes_validate_update +BEFORE UPDATE OF year_id, creator_id, project_id, award_category_id ON votes +BEGIN + SELECT RAISE(ABORT, 'voting is not enabled for this year') + WHERE NOT EXISTS ( + SELECT 1 FROM years WHERE id = NEW.year_id AND voting_enabled = 1 + ); + SELECT RAISE(ABORT, 'vote project must be an active project in vote year') + WHERE NOT EXISTS ( + SELECT 1 FROM projects + WHERE id = NEW.project_id AND year_id = NEW.year_id + AND kind = 'project' AND status = 'active' + ); + SELECT RAISE(ABORT, 'vote category must belong to vote year') + WHERE NOT EXISTS ( + SELECT 1 FROM award_categories + WHERE id = NEW.award_category_id AND year_id = NEW.year_id + ); + SELECT RAISE(ABORT, 'vote project is not eligible for this award category') + WHERE EXISTS ( + SELECT 1 FROM project_nominations WHERE project_id = NEW.project_id + ) AND NOT EXISTS ( + SELECT 1 FROM project_nominations + WHERE project_id = NEW.project_id + AND award_category_id = NEW.award_category_id + ); + SELECT RAISE(ABORT, 'users cannot vote for their own project') + WHERE EXISTS ( + SELECT 1 FROM projects p + WHERE p.id = NEW.project_id + AND ( + p.creator_id = NEW.creator_id + OR EXISTS ( + SELECT 1 FROM project_members pm + WHERE pm.project_id = p.id AND pm.user_id = NEW.creator_id + ) + ) + ); +END; diff --git a/src/shared/administration.ts b/src/shared/administration.ts index 747b21a..0800914 100644 --- a/src/shared/administration.ts +++ b/src/shared/administration.ts @@ -14,6 +14,7 @@ export interface VoteSummary { export interface BallotSelection extends VoteSummary { projectName: string; projectActive: boolean; + nominationEligible: boolean; } export interface BallotStatusResponse { diff --git a/src/worker/repositories/administration.ts b/src/worker/repositories/administration.ts index e3d76ee..f05bcdc 100644 --- a/src/worker/repositories/administration.ts +++ b/src/worker/repositories/administration.ts @@ -37,7 +37,14 @@ export async function getVoting( `SELECT v.id, v.year_id, v.project_id, v.award_category_id, p.name project_name, p.year_id = v.year_id AND p.kind = 'project' AND p.status = 'active' - project_active + project_active, + NOT EXISTS ( + SELECT 1 FROM project_nominations pn WHERE pn.project_id = p.id + ) OR EXISTS ( + SELECT 1 FROM project_nominations pn + WHERE pn.project_id = p.id + AND pn.award_category_id = v.award_category_id + ) nomination_eligible FROM votes v JOIN projects p ON p.id = v.project_id WHERE v.year_id = ? AND v.creator_id = ? @@ -51,6 +58,7 @@ export async function getVoting( award_category_id: string; project_name: string; project_active: number; + nomination_eligible: number; }>(), ]); return { @@ -487,6 +495,7 @@ function mapBallotSelection(row: { award_category_id: string; project_name: string; project_active: number; + nomination_eligible: number; }): BallotSelection { return { id: row.id, @@ -494,6 +503,7 @@ function mapBallotSelection(row: { projectId: row.project_id, projectName: row.project_name, projectActive: Boolean(row.project_active), + nominationEligible: Boolean(row.nomination_eligible), categoryId: row.award_category_id, }; } @@ -527,6 +537,7 @@ function administrationConstraint(cause: unknown, fallback: string) { 'voting is not enabled', 'vote project must', 'vote category must', + 'vote project is not eligible for this award category', 'users cannot vote', 'award references must', 'screening entry must', diff --git a/test/app/administration.test.tsx b/test/app/administration.test.tsx index fde6191..2c83c76 100644 --- a/test/app/administration.test.tsx +++ b/test/app/administration.test.tsx @@ -89,6 +89,7 @@ describe('voting and administration journeys', () => { projectId: 'project', projectName: 'A small machine', projectActive: true, + nominationEligible: true, categoryId: 'impact', }, { @@ -97,6 +98,7 @@ describe('voting and administration journeys', () => { projectId: 'other-project', projectName: 'Quiet hours', projectActive: true, + nominationEligible: true, categoryId: 'craft', }, ], @@ -114,6 +116,7 @@ describe('voting and administration journeys', () => { projectId: 'project', projectName: 'A small machine', projectActive: true, + nominationEligible: true, categoryId: 'delight', }; ballot = {...ballot, votes: [...ballot.votes, selection]}; @@ -126,6 +129,7 @@ describe('voting and administration journeys', () => { projectId: 'project', projectName: 'A small machine', projectActive: true, + nominationEligible: true, categoryId: 'craft', }; ballot = { @@ -257,6 +261,7 @@ describe('voting and administration journeys', () => { projectId: 'other-project', projectName: 'Quiet hours', projectActive: true, + nominationEligible: true, categoryId: 'delight', }, ], diff --git a/test/migration/migration.test.ts b/test/migration/migration.test.ts index adada14..43ac41e 100644 --- a/test/migration/migration.test.ts +++ b/test/migration/migration.test.ts @@ -84,6 +84,11 @@ describe('Firebase migration transformation', () => { 'utf8', ); database.exec(progressMigration); + const nominationEligibilityMigration = await readFile( + path.resolve('migrations/0009_nomination_vote_eligibility.sql'), + 'utf8', + ); + database.exec(nominationEligibilityMigration); expect( database @@ -190,6 +195,102 @@ describe('Firebase migration transformation', () => { } }); + it('preserves historical votes while enforcing nomination eligibility on new writes', async () => { + const database = new DatabaseSync(':memory:'); + try { + const migrations = [ + '0001_initial.sql', + '0002_access_identity.sql', + '0003_voting_administration.sql', + '0004_stream_video_lifecycle.sql', + '0005_google_oauth_sessions.sql', + '0006_session_view_mode.sql', + '0007_r2_video_lifecycle.sql', + '0008_video_processing_progress.sql', + ]; + for (const migration of migrations) { + database.exec(await readFile(path.resolve('migrations', migration), 'utf8')); + } + database.exec(` + INSERT INTO users (id, source_uid, email, display_name) VALUES + ('owner', 'owner', 'owner@example.com', 'Owner'), + ('voter', 'voter', 'voter@example.com', 'Voter'), + ('voter-two', 'voter-two', 'voter-two@example.com', 'Voter Two'); + INSERT INTO years (id, voting_enabled) VALUES ('2026', 1); + INSERT INTO projects (id, source_id, year_id, creator_id, name) VALUES + ('restricted', 'restricted', '2026', 'owner', 'Restricted'), + ('all-categories', 'all-categories', '2026', 'owner', 'All Categories'); + INSERT INTO award_categories + (id, source_id, year_id, name, creator_id) VALUES + ('nominated', 'nominated', '2026', 'Nominated', 'owner'), + ('excluded', 'excluded', '2026', 'Excluded', 'owner'); + INSERT INTO project_nominations + (project_id, award_category_id, position) + VALUES ('restricted', 'nominated', 1); + INSERT INTO votes + (id, source_id, year_id, creator_id, project_id, award_category_id) + VALUES + ('historical-vote', 'historical-vote', '2026', 'voter', 'restricted', 'excluded'); + `); + + database.exec( + await readFile( + path.resolve('migrations/0009_nomination_vote_eligibility.sql'), + 'utf8', + ), + ); + + expect( + database + .prepare( + `SELECT v.project_id, v.award_category_id, pn.position + FROM votes v JOIN project_nominations pn + ON pn.project_id = v.project_id + WHERE v.id = 'historical-vote'`, + ) + .get(), + ).toMatchObject({ + project_id: 'restricted', + award_category_id: 'excluded', + position: 1, + }); + + database.exec(` + INSERT INTO votes + (id, source_id, year_id, creator_id, project_id, award_category_id) + VALUES + ('eligible-vote', 'eligible-vote', '2026', 'voter', 'restricted', 'nominated'); + `); + expect(() => + database.exec(` + INSERT INTO votes + (id, source_id, year_id, creator_id, project_id, award_category_id) + VALUES + ('invalid-vote', 'invalid-vote', '2026', 'voter-two', 'restricted', 'excluded'); + `), + ).toThrow(/vote project is not eligible for this award category/); + + database.exec(` + UPDATE votes SET project_id = 'all-categories' + WHERE id = 'historical-vote'; + `); + expect(() => + database.exec(` + UPDATE votes SET project_id = 'restricted' + WHERE id = 'historical-vote'; + `), + ).toThrow(/vote project is not eligible for this award category/); + expect( + database + .prepare("SELECT project_id FROM votes WHERE id = 'historical-vote'") + .get(), + ).toMatchObject({project_id: 'all-categories'}); + expect(database.prepare('PRAGMA foreign_key_check').all()).toEqual([]); + } finally { + database.close(); + } + }); + it('preserves deterministic IDs, relationships, and storage keys', async () => { const database = await fixture('database.json'); const manifest = await readStorageManifest( diff --git a/test/voting/voting.test.ts b/test/voting/voting.test.ts index 4bd23b1..dc1caa9 100644 --- a/test/voting/voting.test.ts +++ b/test/voting/voting.test.ts @@ -16,6 +16,8 @@ let projectId: string; let ownProjectId: string; let categoryId: string; let secondCategoryId: string; +let excludedCategoryId: string; +let otherYearCategoryId: string; beforeEach(async () => { sequence += 1; @@ -25,6 +27,8 @@ beforeEach(async () => { ownProjectId = `vote-own-project-${sequence}`; categoryId = `vote-category-${sequence}`; secondCategoryId = `vote-category-2-${sequence}`; + excludedCategoryId = `vote-category-3-${sequence}`; + otherYearCategoryId = `vote-category-other-${sequence}`; voterToken = await tokenAndSession('voter'); memberToken = await tokenAndSession('member'); creatorId = `vote-creator-${sequence}`; @@ -59,7 +63,8 @@ beforeEach(async () => { ).bind(ownProjectId, memberId), env.DB.prepare( `INSERT INTO award_categories (id, source_id, year_id, name, creator_id) - VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), (?, ?, ?, ?, ?), + (?, ?, ?, ?, ?)`, ).bind( categoryId, categoryId, @@ -68,6 +73,16 @@ beforeEach(async () => { creatorId, secondCategoryId, secondCategoryId, + yearId, + 'Craft', + creatorId, + excludedCategoryId, + excludedCategoryId, + yearId, + 'Impact', + creatorId, + otherYearCategoryId, + otherYearCategoryId, otherYearId, 'Elsewhere', creatorId, @@ -76,9 +91,17 @@ beforeEach(async () => { }); describe('voting invariants', () => { - it('returns compact current-user ballot status without requiring nominations', async () => { - const created = await api('/votes', voterToken, {method: 'POST', body: voteBody()}); - expect(created.status).toBe(201); + it('treats a nomination-free project as eligible in every year category', async () => { + const created: Awaited>[] = []; + for (const currentCategoryId of [categoryId, secondCategoryId, excludedCategoryId]) { + created.push( + await api('/votes', voterToken, { + method: 'POST', + body: {...voteBody(), categoryId: currentCategoryId}, + }), + ); + } + expect(created.map(({status}) => status)).toEqual([201, 201, 201]); await env.DB.prepare('UPDATE projects SET name = ? WHERE id = ?') .bind('Renamed signal', projectId) .run(); @@ -94,25 +117,169 @@ describe('voting invariants', () => { expect(nominations?.count).toBe(0); expect(voting.body).toEqual({ year: {id: yearId, votingEnabled: true}, - categories: [{id: categoryId, yearId, name: 'Delight'}], - votes: [ - { - id: created.body.vote.id, - yearId, - projectId, - projectName: 'Renamed signal', - projectActive: true, - categoryId, - }, + categories: [ + {id: secondCategoryId, yearId, name: 'Craft'}, + {id: categoryId, yearId, name: 'Delight'}, + {id: excludedCategoryId, yearId, name: 'Impact'}, ], + votes: expect.arrayContaining( + [categoryId, secondCategoryId, excludedCategoryId].map( + (currentCategoryId, index) => ({ + id: created[index].body.vote.id, + yearId, + projectId, + projectName: 'Renamed signal', + projectActive: true, + nominationEligible: true, + categoryId: currentCategoryId, + }), + ), + ), }); + expect(voting.body.votes).toHaveLength(3); expect(otherUser.body).toEqual({ year: {id: yearId, votingEnabled: true}, - categories: [{id: categoryId, yearId, name: 'Delight'}], + categories: [ + {id: secondCategoryId, yearId, name: 'Craft'}, + {id: categoryId, yearId, name: 'Delight'}, + {id: excludedCategoryId, yearId, name: 'Impact'}, + ], votes: [], }); }); + it('accepts nominated categories and atomically rejects excluded casts and moves', async () => { + await env.DB.batch([ + env.DB.prepare( + `INSERT INTO project_nominations + (project_id, award_category_id, position) VALUES (?, ?, 1)`, + ).bind(projectId, categoryId), + env.DB.prepare( + `INSERT INTO project_nominations + (project_id, award_category_id, position) VALUES (?, ?, 2)`, + ).bind(projectId, secondCategoryId), + ]); + + const firstEligible = await api('/votes', voterToken, { + method: 'POST', + body: voteBody(), + }); + const secondEligible = await api('/votes', voterToken, { + method: 'POST', + body: {...voteBody(), categoryId: secondCategoryId}, + }); + const excluded = await api('/votes', voterToken, { + method: 'POST', + body: {...voteBody(), categoryId: excludedCategoryId}, + }); + + const unrestrictedProjectId = `${projectId}-unrestricted`; + await env.DB.prepare( + `INSERT INTO projects (id, source_id, year_id, creator_id, name) + VALUES (?, ?, ?, ?, ?)`, + ) + .bind( + unrestrictedProjectId, + unrestrictedProjectId, + yearId, + creatorId, + 'Unrestricted signal', + ) + .run(); + const movable = await api('/votes', voterToken, { + method: 'POST', + body: { + ...voteBody(), + projectId: unrestrictedProjectId, + categoryId: excludedCategoryId, + }, + }); + const rejectedMove = await api(`/votes/${movable.body.vote.id}`, voterToken, { + method: 'PUT', + body: {...voteBody(), categoryId: excludedCategoryId}, + }); + const storedMove = await env.DB.prepare('SELECT project_id FROM votes WHERE id = ?') + .bind(movable.body.vote.id) + .first<{project_id: string}>(); + + expect([firstEligible.status, secondEligible.status]).toEqual([201, 201]); + for (const response of [excluded, rejectedMove]) { + expect(response).toMatchObject({ + status: 400, + body: { + error: { + code: 'VALIDATION_FAILED', + message: 'vote project is not eligible for this award category', + }, + }, + }); + } + expect(storedMove?.project_id).toBe(unrestrictedProjectId); + const restrictedVotes = await env.DB.prepare( + 'SELECT COUNT(*) count FROM votes WHERE project_id = ?', + ) + .bind(projectId) + .first<{count: number}>(); + expect(restrictedVotes?.count).toBe(2); + }); + + it('keeps a pre-existing active ineligible selection visible and movable', async () => { + const created = await api('/votes', voterToken, {method: 'POST', body: voteBody()}); + await env.DB.prepare( + `INSERT INTO project_nominations + (project_id, award_category_id, position) VALUES (?, ?, 1)`, + ) + .bind(projectId, secondCategoryId) + .run(); + + const ineligibleStatus = await api(`/votes?year=${yearId}`, voterToken); + const storedBeforeMove = await env.DB.prepare( + 'SELECT project_id FROM votes WHERE id = ?', + ) + .bind(created.body.vote.id) + .first<{project_id: string}>(); + expect(storedBeforeMove?.project_id).toBe(projectId); + expect(ineligibleStatus.body.votes).toEqual([ + expect.objectContaining({ + id: created.body.vote.id, + projectId, + projectActive: true, + nominationEligible: false, + categoryId, + }), + ]); + + const replacementProjectId = `${projectId}-replacement`; + await env.DB.prepare( + `INSERT INTO projects (id, source_id, year_id, creator_id, name) + VALUES (?, ?, ?, ?, ?)`, + ) + .bind( + replacementProjectId, + replacementProjectId, + yearId, + creatorId, + 'Replacement signal', + ) + .run(); + const moved = await api(`/votes/${created.body.vote.id}`, voterToken, { + method: 'PUT', + body: {...voteBody(), projectId: replacementProjectId}, + }); + expect(moved.body.vote.projectId).toBe(replacementProjectId); + + const movedStatus = await api(`/votes?year=${yearId}`, voterToken); + expect(movedStatus.body.votes).toEqual([ + expect.objectContaining({ + id: created.body.vote.id, + projectId: replacementProjectId, + projectActive: true, + nominationEligible: true, + categoryId, + }), + ]); + }); + it('keeps a withdrawn project selection visible and movable', async () => { const replacementProjectId = `${projectId}-replacement`; await env.DB.prepare( @@ -139,6 +306,7 @@ describe('voting invariants', () => { projectId, projectName: 'Signal', projectActive: false, + nominationEligible: true, categoryId, }), ]); @@ -156,6 +324,7 @@ describe('voting invariants', () => { projectId: replacementProjectId, projectName: 'Replacement signal', projectActive: true, + nominationEligible: true, categoryId, }), ]); @@ -175,7 +344,7 @@ describe('voting invariants', () => { }); const crossYear = await api('/votes', voterToken, { method: 'POST', - body: {...voteBody(), categoryId: secondCategoryId}, + body: {...voteBody(), categoryId: otherYearCategoryId}, }); const missing = await api('/votes', voterToken, { method: 'POST', @@ -215,7 +384,7 @@ describe('voting invariants', () => { otherYearId, voterId, archivedProjectId, - secondCategoryId, + otherYearCategoryId, ), ]); @@ -225,7 +394,7 @@ describe('voting invariants', () => { body: { yearId: otherYearId, projectId: archivedProjectId, - categoryId: secondCategoryId, + categoryId: otherYearCategoryId, }, }); const replaced = await api(`/votes/${archivedVoteId}`, voterToken, { @@ -233,7 +402,7 @@ describe('voting invariants', () => { body: { yearId: otherYearId, projectId: archivedProjectId, - categoryId: secondCategoryId, + categoryId: otherYearCategoryId, }, }); const deleted = await api(`/votes/${archivedVoteId}`, voterToken, { From 22b1be5ec4ed2b88d0c7bcd641dd46c010294cb8 Mon Sep 17 00:00:00 2001 From: Daniel Griesser Date: Wed, 19 Aug 2026 19:29:42 +0200 Subject: [PATCH 3/6] feat(projects): add award targeting form Add an explicit all-categories default and an accessible focused mode for selecting one or two project award categories. Include nomination choices in form payloads and dirty-state protection, with responsive category cards, native controls, empty-state guidance, and a visible two-selection limit. Initialize edit and claim flows from the correct nomination state, and lock saved targeting while effective voting is open without blocking unrelated edits. Cover new, edit, idea, claim, keyboard, limit, discard, empty, and read-only behavior with focused app tests. --- src/app/components/ProjectForm.tsx | 145 +++++++++++++++++- src/app/routes/ProjectEditorPage.tsx | 11 +- src/app/styles.css | 217 ++++++++++++++++++++++++++- test/app/ProjectForm.test.tsx | 191 ++++++++++++++++++++++- 4 files changed, 555 insertions(+), 9 deletions(-) diff --git a/src/app/components/ProjectForm.tsx b/src/app/components/ProjectForm.tsx index 01b792a..ed32fc5 100644 --- a/src/app/components/ProjectForm.tsx +++ b/src/app/components/ProjectForm.tsx @@ -9,6 +9,7 @@ export function ProjectForm({ claim = false, saving, error, + nominationsReadOnly = false, onCancel, onSubmit, }: { @@ -17,6 +18,7 @@ export function ProjectForm({ claim?: boolean; saving: boolean; error: string | null; + nominationsReadOnly?: boolean; onCancel: () => void; onSubmit: (value: ProjectWriteRequest) => void; }) { @@ -28,11 +30,18 @@ export function ProjectForm({ const [kind, setKind] = useState(initial.kind); const [groupId, setGroupId] = useState(initial.groupId); const [memberIds, setMemberIds] = useState(initial.memberIds); + const [nominationMode, setNominationMode] = useState<'all' | 'focused'>( + initial.nominationCategoryIds.length ? 'focused' : 'all', + ); + const [nominationCategoryIds, setNominationCategoryIds] = useState( + initial.nominationCategoryIds, + ); const [memberQuery, setMemberQuery] = useState(''); const [memberResultsOpen, setMemberResultsOpen] = useState(false); const [highlightedMember, setHighlightedMember] = useState(-1); const memberListboxId = useId(); const memberSearchId = useId(); + const awardTargetingDetailId = useId(); const [needsHelp, setNeedsHelp] = useState(initial.needsHelp); const [helpDetails, setHelpDetails] = useState(initial.helpDetails); @@ -44,11 +53,15 @@ export function ProjectForm({ setKind(project.kind); setGroupId(project.group?.id ?? ''); setMemberIds(project.members.map(({id}) => id)); + setNominationMode(project.nominationCategoryIds.length ? 'focused' : 'all'); + setNominationCategoryIds(project.nominationCategoryIds); setNeedsHelp(project.needsHelp); setHelpDetails(project.helpDetails ?? ''); }, [claim, project]); const users = options.data?.users ?? []; + const categories = options.data?.categories ?? []; + const nominationsLocked = Boolean(project && !claim && nominationsReadOnly); const selectedMembers = memberIds.flatMap((id) => { const member = users.find((user) => user.id === id) ?? @@ -76,7 +89,12 @@ export function ProjectForm({ needsHelp !== initial.needsHelp || helpDetails !== initial.helpDetails || memberIds.length !== initial.memberIds.length || - memberIds.some((id) => !initial.memberIds.includes(id)); + memberIds.some((id) => !initial.memberIds.includes(id)) || + nominationMode !== (initial.nominationCategoryIds.length ? 'focused' : 'all') || + nominationCategoryIds.length !== initial.nominationCategoryIds.length || + nominationCategoryIds.some( + (id, index) => id !== initial.nominationCategoryIds[index], + ); function addMember(id: string) { setMemberIds((members) => (members.includes(id) ? members : [...members, id])); @@ -85,6 +103,16 @@ export function ProjectForm({ setHighlightedMember(-1); } + function toggleNomination(id: string) { + setNominationCategoryIds((selected) => + selected.includes(id) + ? selected.filter((categoryId) => categoryId !== id) + : selected.length < 2 + ? [...selected, id] + : selected, + ); + } + function handleMemberSearchKeyDown(event: KeyboardEvent) { if (event.key === 'Escape' && memberResultsOpen) { event.preventDefault(); @@ -128,13 +156,19 @@ export function ProjectForm({ kind, groupId: kind === 'idea' ? null : groupId || null, memberIds: kind === 'idea' ? [] : memberIds, - nominationCategoryIds: kind === 'idea' ? [] : initial.nominationCategoryIds, + nominationCategoryIds: + kind === 'idea' || nominationMode === 'all' ? [] : nominationCategoryIds, needsHelp: kind === 'project' && needsHelp, helpDetails: kind === 'project' && needsHelp ? helpDetails || null : null, }); } - if (options.isLoading) return

Loading collaborators…

; + if (options.isLoading) + return ( +

+ Loading project options… +

+ ); if (options.error) return

{options.error.message}

; return ( @@ -313,6 +347,111 @@ export function ProjectForm({ ))} +
+ 03 +

choose how this project will show up on the awards ballot.

+
+
+ Award targeting +

+ Keep every category open, or focus the project on one or two awards. +

+ {nominationsLocked && ( +

+ Voting is open. Award targeting is locked so current + ballots stay valid. You can still edit the other project details. +

+ )} +
+ + +
+ {nominationMode === 'focused' && ( +
+
+

Pick at least one category. A maximum of two can be selected.

+ + {nominationCategoryIds.length} of 2 selected + +
+ {categories.length ? ( +
+ {categories.map((category, index) => { + const selected = nominationCategoryIds.includes(category.id); + const atLimit = nominationCategoryIds.length >= 2; + return ( + + ); + })} +
+ ) : ( +

+ Award categories have not been announced. Keep “all award categories” + selected for now. +

+ )} +
+ )} +