All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s
- 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.
359 lines
9.5 KiB
TypeScript
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;
|
|
},
|
|
});
|