convex-polls 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +513 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/index.d.ts +184 -0
  8. package/dist/client/index.d.ts.map +1 -0
  9. package/dist/client/index.js +128 -0
  10. package/dist/client/index.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +36 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +118 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +121 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +78 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/convex.config.d.ts +3 -0
  28. package/dist/component/convex.config.d.ts.map +1 -0
  29. package/dist/component/convex.config.js +3 -0
  30. package/dist/component/convex.config.js.map +1 -0
  31. package/dist/component/polls.d.ts +78 -0
  32. package/dist/component/polls.d.ts.map +1 -0
  33. package/dist/component/polls.js +235 -0
  34. package/dist/component/polls.js.map +1 -0
  35. package/dist/component/schema.d.ts +92 -0
  36. package/dist/component/schema.d.ts.map +1 -0
  37. package/dist/component/schema.js +38 -0
  38. package/dist/component/schema.js.map +1 -0
  39. package/dist/component/votes.d.ts +15 -0
  40. package/dist/component/votes.d.ts.map +1 -0
  41. package/dist/component/votes.js +105 -0
  42. package/dist/component/votes.js.map +1 -0
  43. package/dist/react/index.d.ts +27 -0
  44. package/dist/react/index.d.ts.map +1 -0
  45. package/dist/react/index.js +33 -0
  46. package/dist/react/index.js.map +1 -0
  47. package/package.json +107 -0
  48. package/src/client/_generated/_ignore.ts +1 -0
  49. package/src/client/index.test.ts +90 -0
  50. package/src/client/index.ts +259 -0
  51. package/src/client/setup.test.ts +26 -0
  52. package/src/component/_generated/api.ts +52 -0
  53. package/src/component/_generated/component.ts +145 -0
  54. package/src/component/_generated/dataModel.ts +60 -0
  55. package/src/component/_generated/server.ts +156 -0
  56. package/src/component/convex.config.ts +3 -0
  57. package/src/component/polls.test.ts +202 -0
  58. package/src/component/polls.ts +268 -0
  59. package/src/component/schema.ts +46 -0
  60. package/src/component/setup.test.ts +11 -0
  61. package/src/component/votes.test.ts +356 -0
  62. package/src/component/votes.ts +153 -0
  63. package/src/react/index.ts +85 -0
  64. package/src/test.ts +18 -0
@@ -0,0 +1,153 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server.js";
3
+
4
+ export const castVote = mutation({
5
+ args: {
6
+ pollId: v.id("polls"),
7
+ optionId: v.string(),
8
+ voterId: v.string(),
9
+ },
10
+ returns: v.null(),
11
+ handler: async (ctx, args) => {
12
+ if (!args.voterId.trim()) {
13
+ throw new Error("Voter ID is required.");
14
+ }
15
+
16
+ const poll = await ctx.db.get(args.pollId);
17
+ if (!poll) {
18
+ throw new Error("Poll not found. The poll may have been deleted.");
19
+ }
20
+
21
+ if (poll.status === "closed") {
22
+ throw new Error("This poll is closed and no longer accepting votes.");
23
+ }
24
+
25
+ if (poll.config.closesAt && Date.now() > poll.config.closesAt) {
26
+ await ctx.db.patch(args.pollId, {
27
+ status: "closed",
28
+ closedAt: poll.config.closesAt,
29
+ });
30
+ throw new Error(
31
+ "This poll has expired and is no longer accepting votes.",
32
+ );
33
+ }
34
+
35
+ const validOptionIds = poll.options.map((o) => o.id);
36
+ if (!validOptionIds.includes(args.optionId)) {
37
+ throw new Error(
38
+ "This option doesn't exist on this poll. It may have been removed.",
39
+ );
40
+ }
41
+
42
+ const existingVotes = await ctx.db
43
+ .query("votes")
44
+ .withIndex("by_poll_voter", (q) =>
45
+ q.eq("pollId", args.pollId).eq("voterId", args.voterId),
46
+ )
47
+ .collect();
48
+
49
+ if (!poll.config.allowMultipleVotes && !poll.config.allowChangeVote) {
50
+ if (existingVotes.length > 0) {
51
+ throw new Error(
52
+ "You have already voted on this poll. This poll does not allow changing or adding votes.",
53
+ );
54
+ }
55
+ }
56
+
57
+ if (!poll.config.allowMultipleVotes && poll.config.allowChangeVote) {
58
+ for (const oldVote of existingVotes) {
59
+ await ctx.db.delete(oldVote._id);
60
+ }
61
+ }
62
+
63
+ if (poll.config.allowMultipleVotes) {
64
+ const alreadyVotedForOption = existingVotes.some(
65
+ (v) => v.optionId === args.optionId,
66
+ );
67
+ if (alreadyVotedForOption) {
68
+ throw new Error("You have already voted for this option.");
69
+ }
70
+
71
+ const maxVotes =
72
+ poll.config.maxVotesPerUser ?? poll.options.length;
73
+ if (existingVotes.length >= maxVotes) {
74
+ throw new Error(
75
+ `You can only vote for up to ${maxVotes} option${maxVotes === 1 ? "" : "s"} on this poll.`,
76
+ );
77
+ }
78
+ }
79
+
80
+ await ctx.db.insert("votes", {
81
+ pollId: args.pollId,
82
+ optionId: args.optionId,
83
+ voterId: args.voterId,
84
+ votedAt: Date.now(),
85
+ });
86
+
87
+ return null;
88
+ },
89
+ });
90
+
91
+ export const removeVote = mutation({
92
+ args: {
93
+ pollId: v.id("polls"),
94
+ optionId: v.string(),
95
+ voterId: v.string(),
96
+ },
97
+ returns: v.null(),
98
+ handler: async (ctx, args) => {
99
+ if (!args.voterId.trim()) {
100
+ throw new Error("Voter ID is required.");
101
+ }
102
+
103
+ const poll = await ctx.db.get(args.pollId);
104
+ if (!poll) {
105
+ throw new Error("Poll not found. The poll may have been deleted.");
106
+ }
107
+
108
+ if (poll.status === "closed") {
109
+ throw new Error("Cannot remove votes from a closed poll.");
110
+ }
111
+
112
+ if (poll.config.closesAt && Date.now() > poll.config.closesAt) {
113
+ await ctx.db.patch(args.pollId, {
114
+ status: "closed",
115
+ closedAt: poll.config.closesAt,
116
+ });
117
+ throw new Error("This poll has expired. Votes can no longer be changed.");
118
+ }
119
+
120
+ const votes = await ctx.db
121
+ .query("votes")
122
+ .withIndex("by_poll_voter", (q) =>
123
+ q.eq("pollId", args.pollId).eq("voterId", args.voterId),
124
+ )
125
+ .collect();
126
+
127
+ const voteToRemove = votes.find((v) => v.optionId === args.optionId);
128
+ if (!voteToRemove) {
129
+ throw new Error("No vote found for this option.");
130
+ }
131
+
132
+ await ctx.db.delete(voteToRemove._id);
133
+ return null;
134
+ },
135
+ });
136
+
137
+ export const getUserVotes = query({
138
+ args: {
139
+ pollId: v.id("polls"),
140
+ voterId: v.string(),
141
+ },
142
+ returns: v.array(v.string()),
143
+ handler: async (ctx, args) => {
144
+ const votes = await ctx.db
145
+ .query("votes")
146
+ .withIndex("by_poll_voter", (q) =>
147
+ q.eq("pollId", args.pollId).eq("voterId", args.voterId),
148
+ )
149
+ .collect();
150
+
151
+ return votes.map((v) => v.optionId);
152
+ },
153
+ });
@@ -0,0 +1,85 @@
1
+ "use client";
2
+
3
+ import { useQuery, useMutation } from "convex/react";
4
+ import type { FunctionReference } from "convex/server";
5
+ import type {
6
+ PollWithResults,
7
+ PollListItem,
8
+ } from "../client/index.js";
9
+
10
+ // --- API type for hooks ---
11
+
12
+ export interface PollApi {
13
+ get: FunctionReference<"query", "public">;
14
+ list: FunctionReference<"query", "public">;
15
+ castVote: FunctionReference<"mutation", "public">;
16
+ removeVote: FunctionReference<"mutation", "public">;
17
+ getUserVotes: FunctionReference<"query", "public">;
18
+ }
19
+
20
+ // --- usePoll hook ---
21
+
22
+ export function usePoll(
23
+ api: PollApi,
24
+ pollId: string,
25
+ voterId?: string,
26
+ ): {
27
+ poll: PollWithResults | null | undefined;
28
+ isLoading: boolean;
29
+ vote: (optionId: string) => Promise<null>;
30
+ removeVote: (optionId: string) => Promise<null>;
31
+ hasVoted: boolean;
32
+ userVotes: string[];
33
+ } {
34
+ const poll = useQuery(api.get, { pollId, voterId }) as
35
+ | PollWithResults
36
+ | null
37
+ | undefined;
38
+ const castVoteMutation = useMutation(api.castVote);
39
+ const removeVoteMutation = useMutation(api.removeVote);
40
+
41
+ return {
42
+ poll,
43
+ isLoading: poll === undefined,
44
+ vote: (optionId: string) =>
45
+ castVoteMutation({
46
+ pollId,
47
+ optionId,
48
+ voterId: voterId!,
49
+ }) as Promise<null>,
50
+ removeVote: (optionId: string) =>
51
+ removeVoteMutation({
52
+ pollId,
53
+ optionId,
54
+ voterId: voterId!,
55
+ }) as Promise<null>,
56
+ hasVoted: (poll?.userVotes?.length ?? 0) > 0,
57
+ userVotes: poll?.userVotes ?? [],
58
+ };
59
+ }
60
+
61
+ // --- usePollList hook ---
62
+
63
+ export function usePollList(
64
+ api: Pick<PollApi, "list">,
65
+ args?: {
66
+ status?: "active" | "closed" | "scheduled";
67
+ createdBy?: string;
68
+ limit?: number;
69
+ },
70
+ ): {
71
+ polls: PollListItem[];
72
+ isLoading: boolean;
73
+ } {
74
+ const polls = useQuery(api.list, args ?? {}) as
75
+ | PollListItem[]
76
+ | undefined;
77
+
78
+ return {
79
+ polls: polls ?? [],
80
+ isLoading: polls === undefined,
81
+ };
82
+ }
83
+
84
+ // Re-export types for convenience
85
+ export type { PollWithResults, PollListItem } from "../client/index.js";
package/src/test.ts ADDED
@@ -0,0 +1,18 @@
1
+ /// <reference types="vite/client" />
2
+ import type { TestConvex } from "convex-test";
3
+ import type { GenericSchema, SchemaDefinition } from "convex/server";
4
+ import schema from "./component/schema.js";
5
+ const modules = import.meta.glob("./component/**/*.ts");
6
+
7
+ /**
8
+ * Register the component with the test convex instance.
9
+ * @param t - The test convex instance, e.g. from calling `convexTest`.
10
+ * @param name - The name of the component, as registered in convex.config.ts.
11
+ */
12
+ export function register(
13
+ t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
14
+ name: string = "convexPolls",
15
+ ) {
16
+ t.registerComponent(name, schema, modules);
17
+ }
18
+ export default { register, schema, modules };