From 2e3014582bd862f2b5c977848c7c998d9116c996 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 6 Jul 2025 02:38:30 +0000 Subject: [PATCH] Complete poll implementation with fixes and documentation Co-authored-by: CPlusPatch <42910258+CPlusPatch@users.noreply.github.com> --- classes/database/note.ts | 32 ++++++++++++- classes/database/poll.ts | 3 +- docs/polls.md | 99 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 docs/polls.md diff --git a/classes/database/note.ts b/classes/database/note.ts index 0ebce24a..ef013e8f 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -6,6 +6,9 @@ import { MediasToNotes, Notes, NoteToMentions, + PollOptions, + Polls, + PollVotes, Users, } from "@versia/kit/tables"; import { randomUUIDv7 } from "bun"; @@ -56,7 +59,12 @@ type NoteTypeWithRelations = NoteType & { muted: boolean; liked: boolean; reactions: Omit[]; - poll: typeof Poll.$type | null; + poll: (InferSelectModel & { + options: (InferSelectModel & { + votes: InferSelectModel[]; + })[]; + votes: InferSelectModel[]; + }) | null; }; export type NoteTypeWithoutRecursiveRelations = Omit< @@ -693,7 +701,27 @@ export class Note extends BaseInterface { language: null, muted: data.muted, pinned: data.pinned, - poll: data.poll ? data.poll.toApi(userFetching) : null, + poll: data.poll ? { + id: data.poll.id, + expires_at: data.poll.expiresAt, + expired: data.poll.expiresAt ? new Date(data.poll.expiresAt) < new Date() : false, + multiple: data.poll.multiple, + votes_count: data.poll.votesCount, + voters_count: data.poll.votersCount, + options: data.poll.options.map((option) => ({ + title: option.title, + votes_count: data.poll.hideTotals && !((data.poll.expiresAt ? new Date(data.poll.expiresAt) < new Date() : false)) ? null : option.votesCount, + })), + emojis: [], // TODO: Parse emojis from poll options + voted: userFetching ? data.poll.votes.some((vote) => vote.userId === userFetching.id) : undefined, + own_votes: userFetching ? data.poll.votes + .filter((vote) => vote.userId === userFetching.id) + .map((vote) => { + const option = data.poll.options.find((opt) => opt.id === vote.optionId); + return option?.index ?? -1; + }) + .filter((index) => index !== -1) : undefined, + } : null, reblog: data.reblog ? await new Note(data.reblog as NoteTypeWithRelations).toApi( userFetching, diff --git a/classes/database/poll.ts b/classes/database/poll.ts index b2cbc172..a3733507 100644 --- a/classes/database/poll.ts +++ b/classes/database/poll.ts @@ -13,6 +13,7 @@ import { type InferSelectModel, inArray, } from "drizzle-orm"; +import { randomUUIDv7 } from "bun"; import type { z } from "zod"; import type { Poll as PollSchema } from "@versia/client/schemas"; import { BaseInterface } from "./base.ts"; @@ -203,7 +204,7 @@ export class Poll extends BaseInterface { // Insert poll options const optionInserts = options.map((title, index) => ({ - id: crypto.randomUUID(), + id: randomUUIDv7(), pollId: insertedPoll.id, title, index, diff --git a/docs/polls.md b/docs/polls.md new file mode 100644 index 00000000..fa600d30 --- /dev/null +++ b/docs/polls.md @@ -0,0 +1,99 @@ +# Poll Feature Implementation + +This document describes the poll feature implementation for posting notes with polls. + +## API Usage + +### Creating a Note with a Poll + +To create a note with a poll, include the following parameters in your POST request to `/api/v1/statuses`: + +```json +{ + "status": "Which do you prefer?", + "poll[options]": ["Option 1", "Option 2", "Option 3"], + "poll[expires_in]": 3600, + "poll[multiple]": false, + "poll[hide_totals]": false +} +``` + +### Parameters + +- `poll[options]`: Array of poll option strings (2-20 options, max 500 chars each) +- `poll[expires_in]`: Duration in seconds (60 seconds to 100 days) +- `poll[multiple]`: Boolean, allow multiple choice selection (optional, default: false) +- `poll[hide_totals]`: Boolean, hide vote counts until poll ends (optional, default: false) + +### Constraints + +- Polls cannot be attached to posts with media attachments +- Minimum 2 options required +- Maximum options and duration are configurable in server settings + +## API Response + +When fetching a note with a poll, the response includes: + +```json +{ + "id": "note-id", + "content": "Which do you prefer?", + "poll": { + "id": "poll-id", + "expires_at": "2025-01-07T14:11:00.000Z", + "expired": false, + "multiple": false, + "votes_count": 6, + "voters_count": 3, + "options": [ + { + "title": "Option 1", + "votes_count": 4 + }, + { + "title": "Option 2", + "votes_count": 2 + } + ], + "emojis": [], + "voted": true, + "own_votes": [0] + } +} +``` + +## Database Schema + +The implementation adds three new tables: + +### Polls +- `id`: Primary key +- `noteId`: Foreign key to Notes table (unique) +- `expiresAt`: Poll expiration timestamp (nullable) +- `multiple`: Allow multiple choice +- `hideTotals`: Hide vote counts until expired +- `votesCount`: Total vote count +- `votersCount`: Unique voter count + +### PollOptions +- `id`: Primary key +- `pollId`: Foreign key to Polls table +- `title`: Option text +- `index`: Option position (0-based) +- `votesCount`: Votes for this option + +### PollVotes +- `id`: Primary key +- `pollId`: Foreign key to Polls table +- `optionId`: Foreign key to PollOptions table +- `userId`: Foreign key to Users table +- Unique constraint on (pollId, userId, optionId) + +## Implementation Details + +- Polls are created atomically with the note in a database transaction +- Poll data is included in note queries via relations +- Expiration is checked dynamically when converting to API format +- Vote totals can be hidden until poll expires based on `hideTotals` setting +- User vote status and choices are included when user context is available \ No newline at end of file