Skip to content

Commit aa463da

Browse files
authored
Polls v2 (#3550)
1 parent b7daeb8 commit aa463da

File tree

8 files changed

+236
-81
lines changed

8 files changed

+236
-81
lines changed

src/common/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export * from './pagination.input';
3939
export * from './pagination-list';
4040
export * from './grandparent.middleware';
4141
export * from './parent-types';
42-
export * from './poll';
42+
export * as Polls from './poll';
4343
export * from './resource.dto';
4444
export * from './role.dto';
4545
export * from './secured-list';

src/common/poll.ts

Lines changed: 216 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,260 @@
1-
import { setOf } from '@seedcompany/common';
1+
import { cmpBy, groupBy, setOf } from '@seedcompany/common';
2+
import { type Simplify } from 'type-fest';
23

3-
export class PollResults<T> {
4-
constructor(protected readonly data: PollData<T>) {}
5-
6-
/** Returns true if there were any votes */
7-
get anyVotes() {
8-
return this.numberOfVotes > 0;
9-
}
4+
class PollState<Choice, Voter> {
5+
closed = false;
6+
voters = new Set<Voter>();
7+
votes = new Map<Voter, Choice>();
8+
vetoers = new Set<Voter>();
9+
}
1010

11-
/** Returns true if there were no votes */
12-
get noVotes() {
13-
return this.numberOfVotes === 0;
14-
}
11+
class VoteTally<Choice, Voter> {
12+
protected readonly state: PollState<Choice, Voter>;
13+
protected readonly tallies: ReadonlyArray<
14+
Readonly<{
15+
choice: Choice;
16+
count: number;
17+
voters: ReadonlySet<Voter>;
18+
}>
19+
>;
1520

16-
get numberOfVotes() {
17-
return [...this.data.votes.values()].reduce((total, cur) => total + cur, 0);
21+
constructor(state: PollState<Choice, Voter>) {
22+
this.state = state;
23+
this.tallies = groupBy(state.votes, ([, vote]) => vote)
24+
.map((entries) => {
25+
const voters = setOf(entries.map(([voter]) => voter));
26+
return {
27+
choice: entries[0][1],
28+
voters,
29+
count: voters.size,
30+
};
31+
})
32+
.sort(cmpBy([(c) => c.count, 'desc']));
1833
}
1934

20-
get vetoed() {
21-
return this.data.vetoed;
35+
protected get totalVotes() {
36+
return this.state.votes.size;
2237
}
2338

24-
/** Returns if there was a tie for the highest votes */
25-
get tie() {
26-
const [highest, second] = this.sorted;
27-
return highest && second ? highest[1] === second[1] : false;
39+
protected get vetoed() {
40+
return this.state.vetoers.size > 0;
2841
}
2942

3043
/** Returns the largest minority vote (could be majority too), if there was one */
3144
get plurality() {
32-
const [highest, second] = this.sorted;
45+
if (this.vetoed) {
46+
return undefined;
47+
}
48+
const [highest, second] = this.tallies;
3349
if (!highest) {
3450
return undefined;
3551
}
36-
return highest[1] > (second?.[1] ?? 0) ? highest[0] : undefined;
52+
return highest.count > (second?.count ?? 0) ? highest : undefined;
3753
}
3854

39-
/** Returns the majority vote (>50%), if there was one */
55+
/** Returns the majority vote (>50%) if there was one */
4056
get majority() {
41-
const [first] = this.sorted;
57+
if (this.vetoed) {
58+
return undefined;
59+
}
60+
const [first] = this.tallies;
4261
if (!first) {
4362
return undefined;
4463
}
45-
return first[1] > this.numberOfVotes / 2 ? first[0] : undefined;
64+
return first.count > this.totalVotes / 2 ? first : undefined;
4665
}
4766

48-
/** Returns the unanimous vote, if there was one */
67+
/** Returns the unanimous vote if there was one */
4968
get unanimous() {
50-
const all = this.sorted;
51-
return all.length === 1 ? all[0]![0] : undefined;
69+
if (this.vetoed) {
70+
return undefined;
71+
}
72+
const all = this.tallies;
73+
return all.length === 1 ? all[0]! : undefined;
74+
}
75+
}
76+
77+
export type WinnerStrategy = keyof Simplify<VoteTally<any, any>>;
78+
79+
export class PollResult<Choice, Voter = unknown> extends VoteTally<
80+
Choice,
81+
Voter
82+
> {
83+
constructor(
84+
state: PollState<Choice, Voter>,
85+
readonly winnerStrategy: WinnerStrategy,
86+
) {
87+
super(state);
5288
}
5389

54-
/** Returns all votes sorted by most voted first (ties are unaccounted for) */
55-
get allVotes() {
56-
return setOf(this.sorted.map(([vote]) => vote));
90+
/** Returns the winning choice (if there is one), based on the chosen strategy */
91+
get winner() {
92+
return this.winnerTally?.choice;
5793
}
5894

59-
private get sorted() {
60-
return [...this.data.votes].sort((a, b) => b[1] - a[1]);
95+
/** Returns the winner tally (if there is one), based on the chosen strategy */
96+
get winnerTally() {
97+
return this[this.winnerStrategy];
98+
}
99+
100+
/** Returns true if there were any votes */
101+
get anyVotes() {
102+
return this.totalVotes > 0;
103+
}
104+
105+
/** Returns true if there were no votes */
106+
get noVotes() {
107+
return this.totalVotes === 0;
108+
}
109+
110+
get totalVotes() {
111+
return super.totalVotes;
112+
}
113+
114+
get vetoed() {
115+
return super.vetoed;
116+
}
117+
118+
get vetoers(): ReadonlySet<Voter> {
119+
return this.state.vetoers;
120+
}
121+
122+
/** Returns if there was a tie in the tally of the top choices */
123+
get tie() {
124+
const [highest, second] = this.tallies;
125+
return highest && second ? highest.count === second.count : false;
126+
}
127+
128+
/** Returns all tallies sorted by the highest choice first (ties are unaccounted for) */
129+
get allTallies() {
130+
return this.tallies;
131+
}
132+
}
133+
134+
/**
135+
* A collection of ballots.
136+
*/
137+
export class BallotBox<Choice, Voter = unknown> {
138+
/** @internal */
139+
constructor(protected readonly state: PollState<Choice, Voter>) {}
140+
141+
get isClosed() {
142+
return this.state.closed;
143+
}
144+
145+
/**
146+
* Register a new voter.
147+
* Voters can only be registered once.
148+
*/
149+
registerVoter(voter: Voter) {
150+
if (this.state.closed) {
151+
throw new Error('Poll is closed');
152+
}
153+
if (this.state.voters.has(voter)) {
154+
throw new Error('Voter already exists');
155+
}
156+
this.state.voters.add(voter);
157+
return new Ballot(voter, this.state);
158+
}
159+
160+
/**
161+
* Cast a vote as a certain voter.
162+
*
163+
* This can only be done once, per voter, even if the voter identity is the same.
164+
* If you need to redo a vote, you can use {@link registerVoter} to hold the ballot handle,
165+
* and call its {@link vote} multiple times (as long as the {@link Poll} is not closed).
166+
*
167+
* @see Ballot.vote
168+
*/
169+
vote(voter: Voter, choice: Choice) {
170+
this.registerVoter(voter).vote(choice);
171+
}
172+
173+
/**
174+
* Veto the poll as a certain voter.
175+
*
176+
* This can only be done once, per voter, even if the voter identity is the same.
177+
*
178+
* @see Ballot.veto
179+
*/
180+
veto(voter: Voter) {
181+
this.registerVoter(voter).veto();
61182
}
62183
}
63184

64185
/**
65186
* @example
66187
* const poll = new Poll();
67188
*
68-
* poll.noVotes; // true
69-
* poll.vote(true);
70-
* poll.unanimous; // true
71-
* poll.anyVotes; // true
72-
*
73-
* poll.vote(false);
74-
* poll.unanimous; // undefined
75-
* poll.tie; // true
76-
* poll.majority; // undefined
77-
* poll.plurality; // undefined
189+
* poll.vote('Bilbo', true);
190+
* poll.vote('Frodo', true);
191+
* poll.vote('Samwise', false);
78192
*
79-
* poll.vote(true);
80-
* poll.majority; // true
81-
* poll.plurality; // true
193+
* const result = poll.close();
194+
* result.winner.choice; // true
195+
* result.winner.voters; // {Bilbo, Frodo}
82196
*/
83-
export class Poll<T = boolean> extends PollResults<T> implements PollVoter<T> {
84-
// Get a view of this poll, with results hidden.
85-
readonly voter: PollVoter<T> = this;
86-
// Get a readonly view of this poll's results.
87-
readonly results: PollResults<T> = this;
197+
export class Poll<Choice, Voter = unknown> extends BallotBox<Choice, Voter> {
198+
/**
199+
* Configures a poll type, returning a creation handle, and pre-configured types
200+
* for the polling system.
201+
*
202+
* @template Choice - The type representing the choices available in the poll.
203+
* @template Voter - The type representing the voter, defaults to unknown if not specified.
204+
*/
205+
static configured<Choice, Voter = unknown>() {
206+
return {
207+
create: () => new Poll<Choice, Voter>(),
208+
} as unknown as {
209+
create: () => Poll<Choice, Voter>;
210+
$Poll: Poll<Choice, Voter>;
211+
$BallotBox: BallotBox<Choice, Voter>;
212+
$Ballot: Ballot<Choice, Voter>;
213+
$Result: PollResult<Choice, Voter>;
214+
};
215+
}
88216

89217
constructor() {
90-
super(new PollData<T>());
218+
super(new PollState<Choice, Voter>());
91219
}
92220

93-
vote(vote: T) {
94-
this.data.votes.set(vote, (this.data.votes.get(vote) ?? 0) + 1);
221+
readonly ballotBox = new BallotBox(this.state);
222+
223+
get voters(): ReadonlySet<Voter> {
224+
return this.state.voters;
95225
}
96226

97-
veto() {
98-
this.data.vetoed = true;
227+
/**
228+
* Close the poll and return the result.
229+
* This can only be done once.
230+
*/
231+
close(winnerStrategy: WinnerStrategy = 'plurality') {
232+
if (this.state.closed) {
233+
throw new Error('Poll is already closed');
234+
}
235+
this.state.closed = true;
236+
const result = new PollResult(this.state, winnerStrategy);
237+
return result;
99238
}
100239
}
101240

102241
/**
103-
* The mutations available for a poll.
242+
* A handle for a certain poll voter to cast their vote.
104243
*/
105-
export abstract class PollVoter<T> {
244+
export class Ballot<Choice, Voter = unknown> {
245+
/** @internal */
246+
constructor(
247+
readonly voter: Voter,
248+
protected readonly state: PollState<Choice, Voter>,
249+
) {}
250+
106251
/** Cast a vote. */
107-
abstract vote(vote: T): void;
252+
vote(choice: Choice) {
253+
if (this.state.closed) {
254+
throw new Error('Poll is closed');
255+
}
256+
this.state.votes.set(this.voter, choice);
257+
}
108258

109259
/**
110260
* Veto the poll all together.
@@ -113,12 +263,12 @@ export abstract class PollVoter<T> {
113263
* "cancel" the poll / override all other votes.
114264
* Exceptions are for unexpected errors, where this veto would be a logical
115265
* expectation, so throwing is not the best way to handle it.
116-
* This could be enhanced in future to allow a reason for the veto.
266+
* This could be enhanced in the future to allow a reason for the veto.
117267
*/
118-
abstract veto(): void;
119-
}
120-
121-
class PollData<T> {
122-
votes = new Map<T, number>();
123-
vetoed = false;
268+
veto() {
269+
if (this.state.closed) {
270+
throw new Error('Poll is closed');
271+
}
272+
this.state.vetoers.add(this.voter);
273+
}
124274
}

src/components/authorization/handler/can-impersonate-via-privileges.handler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@ export class CanImpersonateViaPrivilegesHandler {
1212
canImpersonate({ session, allow }: CanImpersonateHook) {
1313
const p = this.privileges.for(AssignableRoles);
1414
const granted = session.roles.values().every((role) => p.can('edit', role));
15-
allow.vote(granted);
15+
allow.vote(this, granted);
1616
}
1717
}

src/components/file/media/events/can-update-event.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Inject, Injectable, Optional, Scope } from '@nestjs/common';
22
import { CachedByArg as Once } from '@seedcompany/common';
3-
import { PollVoter } from '~/common';
3+
import { Polls } from '~/common';
44
import { ResourceResolver, ResourcesHost } from '~/core';
55
import { type AnyMedia, MediaUserMetadata } from '../media.dto';
66

@@ -16,7 +16,7 @@ export class CanUpdateMediaUserMetadataEvent {
1616
constructor(
1717
@Optional() readonly media: AnyMedia,
1818
@Optional() readonly input: MediaUserMetadata,
19-
@Optional() readonly allowUpdate: PollVoter<boolean>,
19+
@Optional() readonly allowUpdate: Polls.BallotBox<boolean>,
2020
) {}
2121

2222
@Once() getAttachedResource() {

0 commit comments

Comments
 (0)