import { mutation, query } from "./_generated/server"; import { v } from "convex/values"; const pollOptionValidator = v.object({ id: v.string(), text: v.string(), }); const pollDocValidator = v.object({ _id: v.id("polls"), _creationTime: v.number(), channelId: v.id("channels"), createdBy: v.id("userProfiles"), question: v.string(), options: v.array(pollOptionValidator), allowMultiple: v.boolean(), disclosed: v.boolean(), closed: v.boolean(), closesAt: v.optional(v.number()), createdAt: v.number(), }); const pollReactionValidator = v.object({ emoji: v.string(), count: v.number(), me: v.boolean(), }); const pollResultsValidator = v.object({ poll: pollDocValidator, totals: v.record(v.string(), v.number()), totalVotes: v.number(), myVote: v.union(v.array(v.string()), v.null()), // Aggregated as an array — arrays keep emoji out of object field // names (Convex's return-value validator rejects non-ASCII fields, // same issue we hit on messages.list). reactions: v.array(pollReactionValidator), }); export const create = mutation({ args: { channelId: v.id("channels"), createdBy: v.id("userProfiles"), question: v.string(), options: v.array(pollOptionValidator), allowMultiple: v.boolean(), disclosed: v.boolean(), closesAt: v.optional(v.number()), }, returns: v.id("polls"), handler: async (ctx, args) => { const question = args.question.trim(); if (question.length === 0) { throw new Error("Poll question cannot be empty"); } if (question.length > 500) { throw new Error("Poll question is too long"); } const cleanOptions = args.options .map((o) => ({ id: o.id, text: o.text.trim() })) .filter((o) => o.text.length > 0); if (cleanOptions.length < 2) { throw new Error("Polls need at least 2 options"); } if (cleanOptions.length > 20) { throw new Error("Polls support at most 20 options"); } // Enforce unique option ids so vote diffing is unambiguous. const seen = new Set(); for (const opt of cleanOptions) { if (seen.has(opt.id)) { throw new Error("Duplicate option id"); } seen.add(opt.id); } const pollId = await ctx.db.insert("polls", { channelId: args.channelId, createdBy: args.createdBy, question, options: cleanOptions, allowMultiple: args.allowMultiple, disclosed: args.disclosed, closed: false, closesAt: args.closesAt, createdAt: Date.now(), }); return pollId; }, }); export const vote = mutation({ args: { pollId: v.id("polls"), userId: v.id("userProfiles"), optionIds: v.array(v.string()), }, returns: v.null(), handler: async (ctx, args) => { const poll = await ctx.db.get(args.pollId); if (!poll) throw new Error("Poll not found"); if (poll.closed) throw new Error("Poll is closed"); if (poll.closesAt && poll.closesAt < Date.now()) { throw new Error("Poll has expired"); } // Validate the submitted option ids exist on the poll. const validIds = new Set(poll.options.map((o) => o.id)); for (const id of args.optionIds) { if (!validIds.has(id)) { throw new Error(`Unknown option id "${id}"`); } } if (args.optionIds.length === 0) { throw new Error("Select at least one option"); } if (!poll.allowMultiple && args.optionIds.length > 1) { throw new Error("This poll only allows one answer"); } // Upsert: one row per (pollId, userId). const existing = await ctx.db .query("pollVotes") .withIndex("by_poll_and_user", (q) => q.eq("pollId", args.pollId).eq("userId", args.userId), ) .unique(); if (existing) { await ctx.db.patch(existing._id, { optionIds: args.optionIds, votedAt: Date.now(), }); } else { await ctx.db.insert("pollVotes", { pollId: args.pollId, userId: args.userId, optionIds: args.optionIds, votedAt: Date.now(), }); } return null; }, }); export const clearVote = mutation({ args: { pollId: v.id("polls"), userId: v.id("userProfiles"), }, returns: v.null(), handler: async (ctx, args) => { const existing = await ctx.db .query("pollVotes") .withIndex("by_poll_and_user", (q) => q.eq("pollId", args.pollId).eq("userId", args.userId), ) .unique(); if (existing) { await ctx.db.delete(existing._id); } return null; }, }); export const close = mutation({ args: { pollId: v.id("polls"), userId: v.id("userProfiles"), }, returns: v.null(), handler: async (ctx, args) => { const poll = await ctx.db.get(args.pollId); if (!poll) throw new Error("Poll not found"); if (poll.createdBy !== args.userId) { throw new Error("Only the poll creator can close it"); } if (poll.closed) return null; await ctx.db.patch(args.pollId, { closed: true }); return null; }, }); export const remove = mutation({ args: { pollId: v.id("polls"), userId: v.id("userProfiles"), }, returns: v.null(), handler: async (ctx, args) => { const poll = await ctx.db.get(args.pollId); if (!poll) return null; if (poll.createdBy !== args.userId) { throw new Error("Only the poll creator can delete it"); } const votes = await ctx.db .query("pollVotes") .withIndex("by_poll", (q) => q.eq("pollId", args.pollId)) .collect(); await Promise.all(votes.map((v) => ctx.db.delete(v._id))); const reactions = await ctx.db .query("pollReactions") .withIndex("by_poll", (q) => q.eq("pollId", args.pollId)) .collect(); await Promise.all(reactions.map((r) => ctx.db.delete(r._id))); await ctx.db.delete(args.pollId); return null; }, }); export const listByChannel = query({ args: { channelId: v.id("channels"), }, returns: v.array(pollDocValidator), handler: async (ctx, args) => { const polls = await ctx.db .query("polls") .withIndex("by_channel", (q) => q.eq("channelId", args.channelId)) .order("desc") .collect(); return polls; }, }); export const get = query({ args: { pollId: v.id("polls"), userId: v.optional(v.id("userProfiles")), }, returns: v.union(pollResultsValidator, v.null()), handler: async (ctx, args) => { const poll = await ctx.db.get(args.pollId); if (!poll) return null; const votes = await ctx.db .query("pollVotes") .withIndex("by_poll", (q) => q.eq("pollId", args.pollId)) .collect(); // Tally totals per option. Voters that picked multiple options // each contribute a +1 to every option they picked. const totals: Record = {}; for (const opt of poll.options) { totals[opt.id] = 0; } for (const vote of votes) { for (const id of vote.optionIds) { if (totals[id] !== undefined) { totals[id] += 1; } } } let myVote: string[] | null = null; if (args.userId) { const mine = votes.find((v) => v.userId === args.userId); myVote = mine ? mine.optionIds : null; } // Aggregate reactions into an array of {emoji, count, me} rows. // Using a Map avoids putting unicode surrogates into object field // names, which Convex's return-value validator would reject. const reactionDocs = await ctx.db .query("pollReactions") .withIndex("by_poll", (q) => q.eq("pollId", args.pollId)) .collect(); const reactionMap = new Map(); for (const r of reactionDocs) { let entry = reactionMap.get(r.emoji); if (!entry) { entry = { count: 0, me: false }; reactionMap.set(r.emoji, entry); } entry.count++; if (args.userId && r.userId === args.userId) { entry.me = true; } } const reactions: Array<{ emoji: string; count: number; me: boolean }> = []; reactionMap.forEach((info, emoji) => { reactions.push({ emoji, count: info.count, me: info.me }); }); return { poll, totals, totalVotes: votes.length, myVote, reactions, }; }, }); /** * Toggle-add a reaction on a poll. Idempotent per (pollId, userId, * emoji) — re-adding the same reaction is a no-op. */ export const addReaction = mutation({ args: { pollId: v.id("polls"), userId: v.id("userProfiles"), emoji: v.string(), }, returns: v.null(), handler: async (ctx, args) => { const existing = await ctx.db .query("pollReactions") .withIndex("by_poll_user_emoji", (q) => q .eq("pollId", args.pollId) .eq("userId", args.userId) .eq("emoji", args.emoji), ) .unique(); if (!existing) { await ctx.db.insert("pollReactions", { pollId: args.pollId, userId: args.userId, emoji: args.emoji, }); } return null; }, }); /** * Remove a reaction on a poll. No-op if the user hasn't reacted * with that emoji. */ export const removeReaction = mutation({ args: { pollId: v.id("polls"), userId: v.id("userProfiles"), emoji: v.string(), }, returns: v.null(), handler: async (ctx, args) => { const existing = await ctx.db .query("pollReactions") .withIndex("by_poll_user_emoji", (q) => q .eq("pollId", args.pollId) .eq("userId", args.userId) .eq("emoji", args.emoji), ) .unique(); if (existing) { await ctx.db.delete(existing._id); } return null; }, });