tangled
alpha
login
or
join now
leaflet.pub
/
leaflet
289
fork
atom
a tool for shared writing and social publishing
289
fork
atom
overview
issues
27
pulls
pipelines
made the voting work
cozylittle.house
1 year ago
75a873a5
a2cabe18
+184
-99
6 changed files
expand all
collapse all
unified
split
actions
pollActions.ts
components
Blocks
PollBlock.tsx
PageSWRDataProvider.tsx
drizzle
relations.ts
schema.ts
src
replicache
mutations.ts
+47
actions/pollActions.ts
···
1
1
+
"use server";
2
2
+
import { drizzle } from "drizzle-orm/postgres-js";
3
3
+
import { and, eq, inArray } from "drizzle-orm";
4
4
+
import postgres from "postgres";
5
5
+
import { entities, poll_votes_on_entity } from "drizzle/schema";
6
6
+
import { cookies } from "next/headers";
7
7
+
import { v7 } from "uuid";
8
8
+
9
9
+
export async function getPollData(entity_sets: string[]) {
10
10
+
const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 });
11
11
+
let voter_token = cookies().get("poll_voter_token")?.value;
12
12
+
13
13
+
const db = drizzle(client);
14
14
+
const polls = await db
15
15
+
.select()
16
16
+
.from(poll_votes_on_entity)
17
17
+
.innerJoin(entities, eq(entities.id, poll_votes_on_entity.poll_entity))
18
18
+
.where(and(inArray(entities.set, entity_sets)));
19
19
+
console.log(polls);
20
20
+
return { polls, voter_token };
21
21
+
}
22
22
+
23
23
+
export async function voteOnPoll(poll_entity: string, option_entity: string) {
24
24
+
let voter_token = cookies().get("poll_voter_token")?.value;
25
25
+
if (!voter_token) {
26
26
+
voter_token = v7();
27
27
+
cookies().set("poll_voter_token", voter_token);
28
28
+
}
29
29
+
const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 });
30
30
+
const db = drizzle(client);
31
31
+
32
32
+
const pollVote = await db
33
33
+
.select()
34
34
+
.from(poll_votes_on_entity)
35
35
+
.where(eq(poll_votes_on_entity.poll_entity, poll_entity));
36
36
+
37
37
+
if (
38
38
+
pollVote.find((v) => {
39
39
+
return v.voter_token === voter_token;
40
40
+
})
41
41
+
)
42
42
+
return;
43
43
+
44
44
+
await db
45
45
+
.insert(poll_votes_on_entity)
46
46
+
.values({ option_entity, poll_entity, voter_token });
47
47
+
}
+38
-43
components/Blocks/PollBlock.tsx
···
9
9
import { theme } from "tailwind.config";
10
10
import { useEntity, useReplicache } from "src/replicache";
11
11
import { v7 } from "uuid";
12
12
+
import { usePollData } from "components/PageSWRDataProvider";
13
13
+
import { voteOnPoll } from "actions/pollActions";
12
14
13
15
export const PollBlock = (props: BlockProps) => {
14
16
let { rep } = useReplicache();
···
22
24
);
23
25
24
26
let dataPollOptions = useEntity(props.entityID, "poll/options");
27
27
+
let { data: pollData } = usePollData();
28
28
+
console.log(pollData);
25
29
26
26
-
let [pollOptions, setPollOptions] = useState<
27
27
-
{ value: string; votes: number }[]
28
28
-
>([
29
29
-
{ value: "hello", votes: 2 },
30
30
-
{ value: "hi", votes: 4 },
31
31
-
]);
32
30
let [localPollOptionNames, setLocalPollOptionNames] = useState<{
33
31
[k: string]: string;
34
32
}>({});
33
33
+
let votes =
34
34
+
pollData?.polls.filter(
35
35
+
(v) => v.poll_votes_on_entity.poll_entity === props.entityID,
36
36
+
) || [];
37
37
+
let totalVotes = votes.length;
35
38
36
36
-
let totalVotes = pollOptions.reduce((sum, option) => sum + option.votes, 0);
39
39
+
let votesByOptions = votes.reduce<{ [option: string]: number }>(
40
40
+
(results, vote) => {
41
41
+
results[vote.poll_votes_on_entity.option_entity] =
42
42
+
(results[vote.poll_votes_on_entity.option_entity] || 0) + 1;
43
43
+
return results;
44
44
+
},
45
45
+
{},
46
46
+
);
37
47
38
38
-
let highestVotes = Math.max(...pollOptions.map((option) => option.votes));
39
39
-
let winningIndexes = pollOptions.reduce<number[]>(
40
40
-
(indexes, option, index) => {
41
41
-
if (option.votes === highestVotes) indexes.push(index);
42
42
-
return indexes;
48
48
+
let highestVotes = Math.max(...Object.values(votesByOptions));
49
49
+
50
50
+
let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>(
51
51
+
(winningEntities, [entity, votes]) => {
52
52
+
if (votes === highestVotes) winningEntities.push(entity);
53
53
+
return winningEntities;
43
54
},
44
55
[],
45
56
);
···
60
71
)}
61
72
62
73
{/* Empty state if no options yet */}
63
63
-
{(pollOptions.every((option) => option.value === "") ||
64
64
-
pollOptions.length === 0) &&
65
65
-
pollState !== "editing" && (
66
66
-
<div className="text-center italic text-tertiary text-sm">
67
67
-
no options yet...
68
68
-
</div>
69
69
-
)}
74
74
+
{dataPollOptions.length === 0 && pollState !== "editing" && (
75
75
+
<div className="text-center italic text-tertiary text-sm">
76
76
+
no options yet...
77
77
+
</div>
78
78
+
)}
70
79
71
80
{dataPollOptions.map((option, index) => (
72
81
<PollOption
82
82
+
pollEntity={props.entityID}
73
83
localNameState={localPollOptionNames[option.data.value]}
74
84
setLocalNameState={setLocalPollOptionNames}
75
85
entityID={option.data.value}
76
86
key={option.data.value}
77
87
state={pollState}
78
88
setState={setPollState}
79
79
-
votes={0}
80
80
-
setVotes={(newVotes) => {
81
81
-
setPollOptions((oldOptions) => {
82
82
-
let newOptions = [...oldOptions];
83
83
-
newOptions[index] = {
84
84
-
value: oldOptions[index].value,
85
85
-
votes: newVotes,
86
86
-
};
87
87
-
return newOptions;
88
88
-
});
89
89
-
}}
89
89
+
votes={votesByOptions[option.data.value] || 0}
90
90
totalVotes={totalVotes}
91
91
-
winner={winningIndexes.includes(index)}
92
92
-
removeOption={() => {
93
93
-
setPollOptions((oldOptions) => {
94
94
-
let newOptions = [...oldOptions];
95
95
-
newOptions.splice(index, 1);
96
96
-
return newOptions;
97
97
-
});
98
98
-
}}
91
91
+
winner={winningOptionEntities.includes(option.data.value)}
99
92
/>
100
93
))}
101
94
{!permissions.write ? null : pollState === "editing" ? (
···
133
126
134
127
const PollOption = (props: {
135
128
entityID: string;
129
129
+
pollEntity: string;
136
130
localNameState: string | undefined;
137
131
setLocalNameState: (
138
132
s: (s: { [k: string]: string }) => { [k: string]: string },
···
140
134
state: "editing" | "voting" | "results";
141
135
setState: (state: "editing" | "voting" | "results") => void;
142
136
votes: number;
143
143
-
setVotes: (votes: number) => void;
144
137
totalVotes: number;
145
138
winner: boolean;
146
146
-
removeOption: () => void;
147
139
}) => {
148
140
let { rep } = useReplicache();
141
141
+
let { mutate } = usePollData();
142
142
+
149
143
let optionName = useEntity(props.entityID, "poll-option/name")?.data.value;
150
144
useEffect(() => {
151
145
props.setLocalNameState((s) => ({
···
172
166
onKeyDown={(e) => {
173
167
if (e.key === "Backspace" && !e.currentTarget.value) {
174
168
e.preventDefault();
175
175
-
props.removeOption();
169
169
+
rep?.mutate.removePollOption({ optionEntity: props.entityID });
176
170
}
177
171
}}
178
172
/>
···
181
175
disabled={props.votes > 0}
182
176
className="text-accent-contrast disabled:text-border"
183
177
onMouseDown={() => {
184
184
-
props.removeOption();
178
178
+
rep?.mutate.removePollOption({ optionEntity: props.entityID });
185
179
}}
186
180
>
187
181
<CloseTiny />
···
193
187
className={`pollOption grow max-w-full`}
194
188
onClick={() => {
195
189
props.setState("results");
196
196
-
props.setVotes(props.votes + 1);
190
190
+
voteOnPoll(props.pollEntity, props.entityID);
191
191
+
mutate();
197
192
}}
198
193
>
199
194
{optionName}
+9
components/PageSWRDataProvider.tsx
···
4
4
import { useReplicache } from "src/replicache";
5
5
import useSWR from "swr";
6
6
import { callRPC } from "app/api/rpc/client";
7
7
+
import { getPollData } from "actions/pollActions";
7
8
8
9
export function PageSWRDataProvider(props: {
9
10
leaflet_id: string;
···
29
30
let { permission_token } = useReplicache();
30
31
return useSWR(`rsvp_data`, () =>
31
32
getRSVPData(
33
33
+
permission_token.permission_token_rights.map((pr) => pr.entity_set),
34
34
+
),
35
35
+
);
36
36
+
}
37
37
+
export function usePollData() {
38
38
+
let { permission_token } = useReplicache();
39
39
+
return useSWR(`poll_data`, () =>
40
40
+
getPollData(
32
41
permission_token.permission_token_rights.map((pr) => pr.entity_set),
33
42
),
34
43
);
+45
-26
drizzle/relations.ts
···
1
1
import { relations } from "drizzle-orm/relations";
2
2
-
import { entities, facts, entity_sets, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, custom_domains, custom_domain_routes, phone_rsvps_to_entity, permission_token_on_homepage, permission_token_rights } from "./schema";
2
2
+
import { entities, poll_votes_on_entity, entity_sets, facts, permission_tokens, identities, email_subscriptions_to_entity, email_auth_tokens, phone_rsvps_to_entity, custom_domains, custom_domain_routes, permission_token_on_homepage, permission_token_rights } from "./schema";
3
3
4
4
-
export const factsRelations = relations(facts, ({one}) => ({
5
5
-
entity: one(entities, {
6
6
-
fields: [facts.entity],
7
7
-
references: [entities.id]
4
4
+
export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({
5
5
+
entity_option_entity: one(entities, {
6
6
+
fields: [poll_votes_on_entity.option_entity],
7
7
+
references: [entities.id],
8
8
+
relationName: "poll_votes_on_entity_option_entity_entities_id"
9
9
+
}),
10
10
+
entity_poll_entity: one(entities, {
11
11
+
fields: [poll_votes_on_entity.poll_entity],
12
12
+
references: [entities.id],
13
13
+
relationName: "poll_votes_on_entity_poll_entity_entities_id"
8
14
}),
9
15
}));
10
16
11
17
export const entitiesRelations = relations(entities, ({one, many}) => ({
12
12
-
facts: many(facts),
18
18
+
poll_votes_on_entities_option_entity: many(poll_votes_on_entity, {
19
19
+
relationName: "poll_votes_on_entity_option_entity_entities_id"
20
20
+
}),
21
21
+
poll_votes_on_entities_poll_entity: many(poll_votes_on_entity, {
22
22
+
relationName: "poll_votes_on_entity_poll_entity_entities_id"
23
23
+
}),
13
24
entity_set: one(entity_sets, {
14
25
fields: [entities.set],
15
26
references: [entity_sets.id]
16
27
}),
28
28
+
facts: many(facts),
17
29
permission_tokens: many(permission_tokens),
18
30
email_subscriptions_to_entities: many(email_subscriptions_to_entity),
19
31
phone_rsvps_to_entities: many(phone_rsvps_to_entity),
···
24
36
permission_token_rights: many(permission_token_rights),
25
37
}));
26
38
39
39
+
export const factsRelations = relations(facts, ({one}) => ({
40
40
+
entity: one(entities, {
41
41
+
fields: [facts.entity],
42
42
+
references: [entities.id]
43
43
+
}),
44
44
+
}));
45
45
+
46
46
+
export const identitiesRelations = relations(identities, ({one, many}) => ({
47
47
+
permission_token: one(permission_tokens, {
48
48
+
fields: [identities.home_page],
49
49
+
references: [permission_tokens.id]
50
50
+
}),
51
51
+
email_auth_tokens: many(email_auth_tokens),
52
52
+
custom_domains: many(custom_domains),
53
53
+
permission_token_on_homepages: many(permission_token_on_homepage),
54
54
+
}));
55
55
+
27
56
export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({
57
57
+
identities: many(identities),
28
58
entity: one(entities, {
29
59
fields: [permission_tokens.root_entity],
30
60
references: [entities.id]
31
61
}),
32
32
-
identities: many(identities),
33
62
email_subscriptions_to_entities: many(email_subscriptions_to_entity),
34
63
custom_domain_routes_edit_permission_token: many(custom_domain_routes, {
35
64
relationName: "custom_domain_routes_edit_permission_token_permission_tokens_id"
···
41
70
permission_token_rights: many(permission_token_rights),
42
71
}));
43
72
44
44
-
export const identitiesRelations = relations(identities, ({one, many}) => ({
45
45
-
permission_token: one(permission_tokens, {
46
46
-
fields: [identities.home_page],
47
47
-
references: [permission_tokens.id]
48
48
-
}),
49
49
-
email_auth_tokens: many(email_auth_tokens),
50
50
-
custom_domains: many(custom_domains),
51
51
-
permission_token_on_homepages: many(permission_token_on_homepage),
52
52
-
}));
53
53
-
54
73
export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({
55
74
entity: one(entities, {
56
75
fields: [email_subscriptions_to_entity.entity],
···
69
88
}),
70
89
}));
71
90
72
72
-
export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({
73
73
-
identity: one(identities, {
74
74
-
fields: [custom_domains.identity],
75
75
-
references: [identities.email]
91
91
+
export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({
92
92
+
entity: one(entities, {
93
93
+
fields: [phone_rsvps_to_entity.entity],
94
94
+
references: [entities.id]
76
95
}),
77
77
-
custom_domain_routes: many(custom_domain_routes),
78
96
}));
79
97
80
98
export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({
···
94
112
}),
95
113
}));
96
114
97
97
-
export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({
98
98
-
entity: one(entities, {
99
99
-
fields: [phone_rsvps_to_entity.entity],
100
100
-
references: [entities.id]
115
115
+
export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({
116
116
+
custom_domain_routes: many(custom_domain_routes),
117
117
+
identity: one(identities, {
118
118
+
fields: [custom_domains.identity],
119
119
+
references: [identities.email]
101
120
}),
102
121
}));
103
122
+38
-30
drizzle/schema.ts
···
1
1
-
import { pgTable, foreignKey, pgEnum, uuid, text, jsonb, timestamp, bigint, unique, boolean, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core"
1
1
+
import { pgTable, foreignKey, pgEnum, uuid, timestamp, text, jsonb, bigint, unique, boolean, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core"
2
2
import { sql } from "drizzle-orm"
3
3
4
4
export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3'])
···
14
14
export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in'])
15
15
16
16
17
17
+
export const poll_votes_on_entity = pgTable("poll_votes_on_entity", {
18
18
+
id: uuid("id").defaultRandom().primaryKey().notNull(),
19
19
+
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
20
20
+
poll_entity: uuid("poll_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ),
21
21
+
option_entity: uuid("option_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ),
22
22
+
voter_token: uuid("voter_token").notNull(),
23
23
+
});
24
24
+
25
25
+
export const entities = pgTable("entities", {
26
26
+
id: uuid("id").primaryKey().notNull(),
27
27
+
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
28
28
+
set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ),
29
29
+
});
30
30
+
17
31
export const facts = pgTable("facts", {
18
32
id: uuid("id").primaryKey().notNull(),
19
33
entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ),
···
32
46
last_mutation: bigint("last_mutation", { mode: "number" }).notNull(),
33
47
});
34
48
35
35
-
export const entities = pgTable("entities", {
36
36
-
id: uuid("id").primaryKey().notNull(),
37
37
-
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
38
38
-
set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ),
39
39
-
});
40
40
-
41
49
export const entity_sets = pgTable("entity_sets", {
42
50
id: uuid("id").defaultRandom().primaryKey().notNull(),
43
51
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
44
52
});
45
53
46
46
-
export const permission_tokens = pgTable("permission_tokens", {
47
47
-
id: uuid("id").defaultRandom().primaryKey().notNull(),
48
48
-
root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ),
49
49
-
});
50
50
-
51
54
export const identities = pgTable("identities", {
52
55
id: uuid("id").defaultRandom().primaryKey().notNull(),
53
56
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
···
58
61
return {
59
62
identities_email_key: unique("identities_email_key").on(table.email),
60
63
}
64
64
+
});
65
65
+
66
66
+
export const permission_tokens = pgTable("permission_tokens", {
67
67
+
id: uuid("id").defaultRandom().primaryKey().notNull(),
68
68
+
root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ),
61
69
});
62
70
63
71
export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_entity", {
···
88
96
country_code: text("country_code").notNull(),
89
97
});
90
98
91
91
-
export const custom_domains = pgTable("custom_domains", {
92
92
-
domain: text("domain").primaryKey().notNull(),
93
93
-
identity: text("identity").default('').notNull().references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ),
94
94
-
confirmed: boolean("confirmed").notNull(),
99
99
+
export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", {
95
100
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
101
101
+
phone_number: text("phone_number").notNull(),
102
102
+
country_code: text("country_code").notNull(),
103
103
+
status: rsvp_status("status").notNull(),
104
104
+
id: uuid("id").defaultRandom().primaryKey().notNull(),
105
105
+
entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ),
106
106
+
name: text("name").default('').notNull(),
107
107
+
plus_ones: smallint("plus_ones").default(0).notNull(),
108
108
+
},
109
109
+
(table) => {
110
110
+
return {
111
111
+
unique_phone_number_entities: uniqueIndex("unique_phone_number_entities").on(table.phone_number, table.entity),
112
112
+
}
96
113
});
97
114
98
115
export const custom_domain_routes = pgTable("custom_domain_routes", {
99
116
id: uuid("id").defaultRandom().primaryKey().notNull(),
100
117
domain: text("domain").notNull().references(() => custom_domains.domain),
101
118
route: text("route").notNull(),
102
102
-
view_permission_token: uuid("view_permission_token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ),
103
119
edit_permission_token: uuid("edit_permission_token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ),
120
120
+
view_permission_token: uuid("view_permission_token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ),
104
121
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
105
122
},
106
123
(table) => {
···
109
126
}
110
127
});
111
128
112
112
-
export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", {
129
129
+
export const custom_domains = pgTable("custom_domains", {
130
130
+
domain: text("domain").primaryKey().notNull(),
131
131
+
identity: text("identity").default('').notNull().references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ),
132
132
+
confirmed: boolean("confirmed").notNull(),
113
133
created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(),
114
114
-
phone_number: text("phone_number").notNull(),
115
115
-
country_code: text("country_code").notNull(),
116
116
-
status: rsvp_status("status").notNull(),
117
117
-
id: uuid("id").defaultRandom().primaryKey().notNull(),
118
118
-
entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ),
119
119
-
name: text("name").default('').notNull(),
120
120
-
plus_ones: smallint("plus_ones").default(0).notNull(),
121
121
-
},
122
122
-
(table) => {
123
123
-
return {
124
124
-
unique_phone_number_entities: uniqueIndex("unique_phone_number_entities").on(table.phone_number, table.entity),
125
125
-
}
126
134
});
127
135
128
136
export const permission_token_on_homepage = pgTable("permission_token_on_homepage", {
+7
src/replicache/mutations.ts
···
591
591
});
592
592
};
593
593
594
594
+
const removePollOption: Mutation<{
595
595
+
optionEntity: string;
596
596
+
}> = async (args, ctx) => {
597
597
+
ctx.deleteEntity(args.optionEntity);
598
598
+
};
599
599
+
594
600
export const mutations = {
595
601
retractAttribute,
596
602
addBlock,
···
611
617
createDraft,
612
618
createEntity,
613
619
addPollOption,
620
620
+
removePollOption,
614
621
};