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/migrations/0010_live_nomination_immutability.sql b/migrations/0010_live_nomination_immutability.sql new file mode 100644 index 0000000..9bf61ac --- /dev/null +++ b/migrations/0010_live_nomination_immutability.sql @@ -0,0 +1,40 @@ +PRAGMA foreign_keys = ON; + +CREATE TRIGGER project_nominations_lock_insert +BEFORE INSERT ON project_nominations +BEGIN + SELECT RAISE(ABORT, 'award nominations cannot change while voting is enabled') + WHERE EXISTS ( + SELECT 1 FROM projects p + JOIN years y ON y.id = p.year_id + WHERE p.id = NEW.project_id + AND y.voting_enabled = 1 + AND y.id = (SELECT MAX(id) FROM years) + ); +END; + +CREATE TRIGGER project_nominations_lock_update +BEFORE UPDATE OF project_id, award_category_id, position ON project_nominations +BEGIN + SELECT RAISE(ABORT, 'award nominations cannot change while voting is enabled') + WHERE EXISTS ( + SELECT 1 FROM projects p + JOIN years y ON y.id = p.year_id + WHERE p.id IN (OLD.project_id, NEW.project_id) + AND y.voting_enabled = 1 + AND y.id = (SELECT MAX(id) FROM years) + ); +END; + +CREATE TRIGGER project_nominations_lock_delete +BEFORE DELETE ON project_nominations +BEGIN + SELECT RAISE(ABORT, 'award nominations cannot change while voting is enabled') + WHERE EXISTS ( + SELECT 1 FROM projects p + JOIN years y ON y.id = p.year_id + WHERE p.id = OLD.project_id + AND y.voting_enabled = 1 + AND y.id = (SELECT MAX(id) FROM years) + ); +END; diff --git a/src/app/components/ProjectForm.tsx b/src/app/components/ProjectForm.tsx index 6898f0f..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,12 +156,19 @@ export function ProjectForm({ kind, groupId: kind === 'idea' ? null : groupId || null, memberIds: kind === 'idea' ? [] : memberIds, + 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 ( @@ -312,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. +

+ )} +
+ )} +