Skip to content

Commit 7d302aa

Browse files
authored
feat: implement poll status management with start and end functionality (#906)
* feat: implement poll status management with start and end functionality * fix: refine error handling for poll start and end authorization * fix: correct ordinal suffix for ranking display
1 parent bddfe7a commit 7d302aa

12 files changed

Lines changed: 363 additions & 52 deletions

File tree

platforms/evoting/api/scripts/seed.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -486,7 +486,33 @@ async function seed() {
486486

487487
console.log(`\n📊 Polls created: ${pollsCreated}, Votes created: ${votesCreated}\n`);
488488

489-
// Optionally add a custom user to all groups
489+
// Auto-add all non-seed users (created via dev-sandbox auth/webhooks) to all groups
490+
const seedEnames = new Set(seedUsers.map(u => u.ename));
491+
const allUsers = await userRepo.find();
492+
const nonSeedUsers = allUsers.filter(u => !seedEnames.has(u.ename));
493+
494+
if (nonSeedUsers.length > 0) {
495+
console.log(`👤 Adding ${nonSeedUsers.length} existing user(s) to all groups...`);
496+
497+
const groupsToUpdate = await groupRepo.find({
498+
relations: ["members", "participants"],
499+
});
500+
501+
for (const user of nonSeedUsers) {
502+
for (const group of groupsToUpdate) {
503+
const isAlreadyMember = group.members.some((m) => m.id === user.id);
504+
505+
if (!isAlreadyMember) {
506+
group.members.push(user);
507+
group.participants.push(user);
508+
await groupRepo.save(group);
509+
console.log(` ✅ Added "${user.name || user.ename}" to "${group.name}"`);
510+
}
511+
}
512+
}
513+
}
514+
515+
// Also support explicit ename argument
490516
const customEname = process.argv[2];
491517
if (customEname) {
492518
console.log(`\n👤 Adding custom user to all groups...`);
@@ -532,7 +558,8 @@ async function seed() {
532558

533559
console.log("\n🎉 Seed completed successfully!\n");
534560
console.log("Summary:");
535-
console.log(` - ${seedUsers.length} users`);
561+
console.log(` - ${seedUsers.length} seed users`);
562+
console.log(` - ${nonSeedUsers.length} existing user(s) added to all groups`);
536563
console.log(` - ${seedGroups.length} groups (with charters)`);
537564
console.log(` - ${seedPolls.length} polls (various modes: normal, point, rank)`);
538565
console.log(` - Includes public and private polls with sample votes`);

platforms/evoting/api/src/controllers/PollController.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,50 @@ export class PollController {
156156
res.status(500).json({ error: "Failed to fetch user polls" });
157157
}
158158
};
159+
160+
startPoll = async (req: Request, res: Response) => {
161+
try {
162+
const { id } = req.params;
163+
const userId = (req as any).user.id;
164+
const poll = await this.pollService.startPoll(id, userId);
165+
res.json(poll);
166+
} catch (error) {
167+
console.error("Error starting poll:", error);
168+
if (error instanceof Error) {
169+
if (error.message === "Poll not found") {
170+
return res.status(404).json({ error: error.message });
171+
}
172+
if (error.message === "Not authorized to start this poll") {
173+
return res.status(403).json({ error: error.message });
174+
}
175+
if (error.message === "Only draft polls can be started") {
176+
return res.status(400).json({ error: error.message });
177+
}
178+
}
179+
res.status(500).json({ error: "Failed to start poll" });
180+
}
181+
};
182+
183+
endPoll = async (req: Request, res: Response) => {
184+
try {
185+
const { id } = req.params;
186+
const userId = (req as any).user.id;
187+
const poll = await this.pollService.endPoll(id, userId);
188+
res.json(poll);
189+
} catch (error) {
190+
console.error("Error ending poll:", error);
191+
if (error instanceof Error) {
192+
if (error.message === "Poll not found") {
193+
return res.status(404).json({ error: error.message });
194+
}
195+
if (error.message === "Not authorized to end this poll") {
196+
return res.status(403).json({ error: error.message });
197+
}
198+
if (error.message === "Only active polls can be ended") {
199+
return res.status(400).json({ error: error.message });
200+
}
201+
}
202+
res.status(500).json({ error: "Failed to end poll" });
203+
}
204+
};
159205
}

platforms/evoting/api/src/database/entities/Poll.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ export class Poll {
4040
@Column("simple-array")
4141
options!: string[]; // stored as comma-separated values
4242

43+
@Column("enum", {
44+
enum: ["draft", "active", "ended"],
45+
default: "active",
46+
})
47+
status!: "draft" | "active" | "ended";
48+
4349
@Column({ type: "timestamp", nullable: true })
4450
deadline!: Date | null;
4551

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { MigrationInterface, QueryRunner } from "typeorm";
2+
3+
export class Migration1772851200000 implements MigrationInterface {
4+
name = 'Migration1772851200000'
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(`CREATE TYPE "public"."polls_status_enum" AS ENUM('draft', 'active', 'ended')`);
8+
await queryRunner.query(`ALTER TABLE "polls" ADD "status" "public"."polls_status_enum" NOT NULL DEFAULT 'active'`);
9+
}
10+
11+
public async down(queryRunner: QueryRunner): Promise<void> {
12+
await queryRunner.query(`ALTER TABLE "polls" DROP COLUMN "status"`);
13+
await queryRunner.query(`DROP TYPE "public"."polls_status_enum"`);
14+
}
15+
}

platforms/evoting/api/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ app.get("/api/polls/my", authGuard, pollController.getPollsByCreator);
209209
app.post("/api/polls", authGuard, pollController.createPoll);
210210
app.put("/api/polls/:id", authGuard, pollController.updatePoll);
211211
app.delete("/api/polls/:id", authGuard, pollController.deletePoll);
212+
app.post("/api/polls/:id/start", authGuard, pollController.startPoll);
213+
app.post("/api/polls/:id/end", authGuard, pollController.endPoll);
212214

213215
// Vote routes
214216
app.post("/api/votes", voteController.createVote.bind(voteController)); // Create normal/point/rank vote (old format)

platforms/evoting/api/src/services/DeadlineCheckService.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ export class DeadlineCheckService {
8080
voteId: poll.id
8181
});
8282

83-
// Mark that we've sent the deadline message
84-
await this.pollRepository.update(poll.id, { deadlineMessageSent: true });
83+
// Mark that we've sent the deadline message and set status to ended
84+
await this.pollRepository.update(poll.id, { deadlineMessageSent: true, status: "ended" });
8585

8686
console.log(`Successfully sent deadline message for poll: ${poll.title} (${poll.id})`);
8787

platforms/evoting/api/src/services/PollService.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,8 @@ export class PollService {
8686
// Custom sorting based on sortField and sortDirection
8787
const sortedPolls = filteredPolls.sort((a, b) => {
8888
const now = new Date();
89-
const aIsActive = !a.deadline || new Date(a.deadline) > now;
90-
const bIsActive = !b.deadline || new Date(b.deadline) > now;
89+
const aIsActive = a.status === "active" || (a.status !== "ended" && a.status !== "draft" && (!a.deadline || new Date(a.deadline) > now));
90+
const bIsActive = b.status === "active" || (b.status !== "ended" && b.status !== "draft" && (!b.deadline || new Date(b.deadline) > now));
9191

9292
// Apply the user's chosen sorting
9393
let comparison = 0;
@@ -218,13 +218,15 @@ export class PollService {
218218
throw new Error("Blind voting (private visibility) cannot be combined with eReputation weighted voting.");
219219
}
220220

221+
const hasDeadline = !!pollData.deadline;
221222
const pollDataForEntity = {
222223
title: pollData.title,
223224
mode: pollData.mode as "normal" | "point" | "rank",
224225
visibility: pollData.visibility as "public" | "private",
225226
votingWeight: votingWeight,
226227
options: pollData.options,
227-
deadline: pollData.deadline ? new Date(pollData.deadline) : null,
228+
status: hasDeadline ? "active" as const : "draft" as const,
229+
deadline: hasDeadline ? new Date(pollData.deadline!) : null,
228230
creator,
229231
creatorId: pollData.creatorId,
230232
groupId: pollData.groupId || null
@@ -237,8 +239,8 @@ export class PollService {
237239
const savedPoll = await this.pollRepository.save(poll);
238240
console.log('🔍 Poll saved to database:', savedPoll);
239241

240-
// Create a system message about the new vote
241-
if (pollData.groupId) {
242+
// Create a system message about the new vote (only for active polls, not drafts)
243+
if (pollData.groupId && hasDeadline) {
242244
await this.messageService.createVoteCreatedMessage(pollData.groupId, pollData.title, savedPoll.id, creator.name, savedPoll.deadline);
243245
}
244246

@@ -301,6 +303,67 @@ export class PollService {
301303

302304

303305

306+
/**
307+
* Manually start a poll (draft → active). Only creator can do this.
308+
*/
309+
async startPoll(id: string, userId: string): Promise<Poll> {
310+
const poll = await this.getPollById(id);
311+
if (!poll) {
312+
throw new Error("Poll not found");
313+
}
314+
if (poll.creatorId !== userId) {
315+
throw new Error("Not authorized to start this poll");
316+
}
317+
if (poll.status !== "draft") {
318+
throw new Error("Only draft polls can be started");
319+
}
320+
321+
await this.pollRepository.update(id, { status: "active" });
322+
323+
// Send system message that voting is now open
324+
if (poll.groupId) {
325+
await this.messageService.createVoteCreatedMessage(
326+
poll.groupId,
327+
poll.title,
328+
poll.id,
329+
poll.creator.name,
330+
poll.deadline
331+
);
332+
}
333+
334+
return (await this.getPollById(id))!;
335+
}
336+
337+
/**
338+
* Manually end a poll (active → ended). Only creator can do this.
339+
*/
340+
async endPoll(id: string, userId: string): Promise<Poll> {
341+
const poll = await this.getPollById(id);
342+
if (!poll) {
343+
throw new Error("Poll not found");
344+
}
345+
if (poll.creatorId !== userId) {
346+
throw new Error("Not authorized to end this poll");
347+
}
348+
if (poll.status !== "active") {
349+
throw new Error("Only active polls can be ended");
350+
}
351+
352+
await this.pollRepository.update(id, { status: "ended", deadlineMessageSent: true });
353+
354+
// Send system message that voting has ended
355+
if (poll.groupId) {
356+
const voteUrl = `${process.env.PUBLIC_EVOTING_URL || 'http://localhost:3000'}/${poll.id}`;
357+
await this.messageService.createSystemMessage({
358+
text: `eVoting Platform: Vote has been manually closed!\n\n"${poll.title}"\n\nVote ID: ${poll.id}\n\nClosed by: ${poll.creator.name}\n\n<a href="${voteUrl}" target="_blank">View results here</a>`,
359+
groupId: poll.groupId,
360+
voteId: poll.id
361+
});
362+
}
363+
364+
return (await this.getPollById(id))!;
365+
}
366+
304367
/**
305368
* Get polls by group ID
306369
*/

platforms/evoting/api/src/services/VoteService.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ export class VoteService {
115115
throw new Error('Poll not found');
116116
}
117117

118+
// Prevent voting on polls that are not active
119+
if (poll.status === "draft") {
120+
throw new Error('Voting has not started yet');
121+
}
122+
if (poll.status === "ended") {
123+
throw new Error('Voting has ended');
124+
}
125+
118126
// Check if user has already voted
119127
const existingVote = await this.voteRepository.findOne({
120128
where: { pollId, userId }
@@ -178,6 +186,14 @@ export class VoteService {
178186
throw new Error('Poll not found');
179187
}
180188

189+
// Prevent voting on polls that are not active
190+
if (poll.status === "draft") {
191+
throw new Error('Voting has not started yet');
192+
}
193+
if (poll.status === "ended") {
194+
throw new Error('Voting has ended');
195+
}
196+
181197
const delegation = await this.delegationRepository.findOne({
182198
where: {
183199
pollId,
@@ -660,6 +676,14 @@ export class VoteService {
660676
throw new Error('Poll not found');
661677
}
662678

679+
// Prevent voting on polls that are not active
680+
if (poll.status === "draft") {
681+
throw new Error('Voting has not started yet');
682+
}
683+
if (poll.status === "ended") {
684+
throw new Error('Voting has ended');
685+
}
686+
663687
// Create election if it doesn't exist
664688
try {
665689
const options = poll.options.map((opt: string, index: number) => `option_${index}`);

0 commit comments

Comments
 (0)