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
83 changes: 83 additions & 0 deletions migrations/0009_nomination_vote_eligibility.sql
Original file line number Diff line number Diff line change
@@ -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;
40 changes: 40 additions & 0 deletions migrations/0010_live_nomination_immutability.sql
Original file line number Diff line number Diff line change
@@ -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;
145 changes: 143 additions & 2 deletions src/app/components/ProjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function ProjectForm({
claim = false,
saving,
error,
nominationsReadOnly = false,
onCancel,
onSubmit,
}: {
Expand All @@ -17,6 +18,7 @@ export function ProjectForm({
claim?: boolean;
saving: boolean;
error: string | null;
nominationsReadOnly?: boolean;
onCancel: () => void;
onSubmit: (value: ProjectWriteRequest) => void;
}) {
Expand All @@ -28,11 +30,18 @@ export function ProjectForm({
const [kind, setKind] = useState<ProjectWriteRequest['kind']>(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);

Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Create and claim ignore voting lock

Medium Severity

nominationsLocked is true only when an existing project is being edited, so create and claim still allow focused award targeting after voting opens. Those writes are rejected by the database lock, so teams can fill in one or two categories and then hit a conflict instead of seeing targeting frozen like the edit form.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d71154c. Configure here.

const selectedMembers = memberIds.flatMap((id) => {
const member =
users.find((user) => user.id === id) ??
Expand Down Expand Up @@ -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]));
Expand All @@ -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<HTMLInputElement>) {
if (event.key === 'Escape' && memberResultsOpen) {
event.preventDefault();
Expand Down Expand Up @@ -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 <p className="formNotice">Loading collaborators…</p>;
if (options.isLoading)
return (
<p className="formNotice" role="status">
Loading project options…
</p>
);
if (options.error) return <p className="formError">{options.error.message}</p>;

return (
Expand Down Expand Up @@ -312,6 +347,111 @@ export function ProjectForm({
))}
</div>
</fieldset>
<div className="formIntro">
<span>03</span>
<p>choose how this project will show up on the awards ballot.</p>
</div>
<fieldset
className={`awardTargeting${nominationsLocked ? ' awardTargeting--locked' : ''}`}
disabled={nominationsLocked}
aria-describedby={awardTargetingDetailId}
>
<legend>Award targeting</legend>
<p className="awardTargetingIntro" id={awardTargetingDetailId}>
Keep every category open, or focus the project on one or two awards.
</p>
{nominationsLocked && (
<p className="awardTargetingLock" role="note">
<strong>Voting is open.</strong> Award targeting is locked so current
ballots stay valid. You can still edit the other project details.
</p>
)}
<div className="awardModes">
<label
className={`awardModeCard${nominationMode === 'all' ? ' awardModeCard--selected' : ''}`}
>
<input
type="radio"
name="nominationMode"
value="all"
checked={nominationMode === 'all'}
onChange={() => {
setNominationMode('all');
setNominationCategoryIds([]);
}}
/>
<span className="awardModeIcon" aria-hidden="true">
</span>
<span>
<strong>All award categories</strong>
<small>Voters can consider this project for every award.</small>
</span>
</label>
<label
className={`awardModeCard${nominationMode === 'focused' ? ' awardModeCard--selected' : ''}${!categories.length ? ' awardModeCard--unavailable' : ''}`}
>
<input
type="radio"
name="nominationMode"
value="focused"
checked={nominationMode === 'focused'}
disabled={!categories.length || nominationsLocked}
onChange={() => setNominationMode('focused')}
/>
<span className="awardModeIcon" aria-hidden="true">
1–2
</span>
<span>
<strong>Focus on specific awards</strong>
<small>
{categories.length
? 'Choose one or two categories this project is aiming for.'
: 'No award categories are available yet.'}
</small>
</span>
</label>
</div>
{nominationMode === 'focused' && (
<div className="awardCategoryPicker">
<div className="awardCategoryPickerHeader">
<p>Pick at least one category. A maximum of two can be selected.</p>
<output aria-live="polite">
{nominationCategoryIds.length} of 2 selected
</output>
</div>
{categories.length ? (
<div className="awardCategoryChoices">
{categories.map((category, index) => {
const selected = nominationCategoryIds.includes(category.id);
const atLimit = nominationCategoryIds.length >= 2;
return (
<label
className={`awardCategoryChoice${selected ? ' awardCategoryChoice--selected' : ''}${atLimit && !selected ? ' awardCategoryChoice--limited' : ''}`}
key={category.id}
>
<input
type="checkbox"
checked={selected}
required={index === 0 && nominationCategoryIds.length === 0}
disabled={nominationsLocked || (atLimit && !selected)}
onChange={() => toggleNomination(category.id)}
/>
<span>{category.name}</span>
{atLimit && !selected && <small>two selected</small>}
</label>
);
})}
</div>
) : (
<p className="awardCategoryEmpty" role="status">
Award categories have not been announced. Keep “all award categories”
selected for now.
</p>
)}
</div>
)}
</fieldset>
<label className="checkField">
<input
type="checkbox"
Expand Down Expand Up @@ -369,6 +509,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 ?? '',
};
Expand Down
Loading
Loading