Files
DiscordClone/convex/polls.ts
Bryan1029384756 b7a4cf4ce8
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
2026-04-14 09:02:14 -05:00

359 lines
9.5 KiB
TypeScript

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<string>();
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<string, number> = {};
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<string, { count: number; me: boolean }>();
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;
},
});