a tool for shared writing and social publishing

made the voting work

+184 -99
+47
actions/pollActions.ts
··· 1 + "use server"; 2 + import { drizzle } from "drizzle-orm/postgres-js"; 3 + import { and, eq, inArray } from "drizzle-orm"; 4 + import postgres from "postgres"; 5 + import { entities, poll_votes_on_entity } from "drizzle/schema"; 6 + import { cookies } from "next/headers"; 7 + import { v7 } from "uuid"; 8 + 9 + export async function getPollData(entity_sets: string[]) { 10 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 11 + let voter_token = cookies().get("poll_voter_token")?.value; 12 + 13 + const db = drizzle(client); 14 + const polls = await db 15 + .select() 16 + .from(poll_votes_on_entity) 17 + .innerJoin(entities, eq(entities.id, poll_votes_on_entity.poll_entity)) 18 + .where(and(inArray(entities.set, entity_sets))); 19 + console.log(polls); 20 + return { polls, voter_token }; 21 + } 22 + 23 + export async function voteOnPoll(poll_entity: string, option_entity: string) { 24 + let voter_token = cookies().get("poll_voter_token")?.value; 25 + if (!voter_token) { 26 + voter_token = v7(); 27 + cookies().set("poll_voter_token", voter_token); 28 + } 29 + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); 30 + const db = drizzle(client); 31 + 32 + const pollVote = await db 33 + .select() 34 + .from(poll_votes_on_entity) 35 + .where(eq(poll_votes_on_entity.poll_entity, poll_entity)); 36 + 37 + if ( 38 + pollVote.find((v) => { 39 + return v.voter_token === voter_token; 40 + }) 41 + ) 42 + return; 43 + 44 + await db 45 + .insert(poll_votes_on_entity) 46 + .values({ option_entity, poll_entity, voter_token }); 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 + import { usePollData } from "components/PageSWRDataProvider"; 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 + let { data: pollData } = usePollData(); 28 + console.log(pollData); 25 29 26 - let [pollOptions, setPollOptions] = useState< 27 - { value: string; votes: number }[] 28 - >([ 29 - { value: "hello", votes: 2 }, 30 - { value: "hi", votes: 4 }, 31 - ]); 32 30 let [localPollOptionNames, setLocalPollOptionNames] = useState<{ 33 31 [k: string]: string; 34 32 }>({}); 33 + let votes = 34 + pollData?.polls.filter( 35 + (v) => v.poll_votes_on_entity.poll_entity === props.entityID, 36 + ) || []; 37 + let totalVotes = votes.length; 35 38 36 - let totalVotes = pollOptions.reduce((sum, option) => sum + option.votes, 0); 39 + let votesByOptions = votes.reduce<{ [option: string]: number }>( 40 + (results, vote) => { 41 + results[vote.poll_votes_on_entity.option_entity] = 42 + (results[vote.poll_votes_on_entity.option_entity] || 0) + 1; 43 + return results; 44 + }, 45 + {}, 46 + ); 37 47 38 - let highestVotes = Math.max(...pollOptions.map((option) => option.votes)); 39 - let winningIndexes = pollOptions.reduce<number[]>( 40 - (indexes, option, index) => { 41 - if (option.votes === highestVotes) indexes.push(index); 42 - return indexes; 48 + let highestVotes = Math.max(...Object.values(votesByOptions)); 49 + 50 + let winningOptionEntities = Object.entries(votesByOptions).reduce<string[]>( 51 + (winningEntities, [entity, votes]) => { 52 + if (votes === highestVotes) winningEntities.push(entity); 53 + return winningEntities; 43 54 }, 44 55 [], 45 56 ); ··· 60 71 )} 61 72 62 73 {/* Empty state if no options yet */} 63 - {(pollOptions.every((option) => option.value === "") || 64 - pollOptions.length === 0) && 65 - pollState !== "editing" && ( 66 - <div className="text-center italic text-tertiary text-sm"> 67 - no options yet... 68 - </div> 69 - )} 74 + {dataPollOptions.length === 0 && pollState !== "editing" && ( 75 + <div className="text-center italic text-tertiary text-sm"> 76 + no options yet... 77 + </div> 78 + )} 70 79 71 80 {dataPollOptions.map((option, index) => ( 72 81 <PollOption 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 - votes={0} 80 - setVotes={(newVotes) => { 81 - setPollOptions((oldOptions) => { 82 - let newOptions = [...oldOptions]; 83 - newOptions[index] = { 84 - value: oldOptions[index].value, 85 - votes: newVotes, 86 - }; 87 - return newOptions; 88 - }); 89 - }} 89 + votes={votesByOptions[option.data.value] || 0} 90 90 totalVotes={totalVotes} 91 - winner={winningIndexes.includes(index)} 92 - removeOption={() => { 93 - setPollOptions((oldOptions) => { 94 - let newOptions = [...oldOptions]; 95 - newOptions.splice(index, 1); 96 - return newOptions; 97 - }); 98 - }} 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 + 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 - setVotes: (votes: number) => void; 144 137 totalVotes: number; 145 138 winner: boolean; 146 - removeOption: () => void; 147 139 }) => { 148 140 let { rep } = useReplicache(); 141 + let { mutate } = usePollData(); 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 - props.removeOption(); 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 - props.removeOption(); 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 - props.setVotes(props.votes + 1); 190 + voteOnPoll(props.pollEntity, props.entityID); 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 + 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 + permission_token.permission_token_rights.map((pr) => pr.entity_set), 34 + ), 35 + ); 36 + } 37 + export function usePollData() { 38 + let { permission_token } = useReplicache(); 39 + return useSWR(`poll_data`, () => 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 - 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 + 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 - export const factsRelations = relations(facts, ({one}) => ({ 5 - entity: one(entities, { 6 - fields: [facts.entity], 7 - references: [entities.id] 4 + export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ 5 + entity_option_entity: one(entities, { 6 + fields: [poll_votes_on_entity.option_entity], 7 + references: [entities.id], 8 + relationName: "poll_votes_on_entity_option_entity_entities_id" 9 + }), 10 + entity_poll_entity: one(entities, { 11 + fields: [poll_votes_on_entity.poll_entity], 12 + references: [entities.id], 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 - facts: many(facts), 18 + poll_votes_on_entities_option_entity: many(poll_votes_on_entity, { 19 + relationName: "poll_votes_on_entity_option_entity_entities_id" 20 + }), 21 + poll_votes_on_entities_poll_entity: many(poll_votes_on_entity, { 22 + relationName: "poll_votes_on_entity_poll_entity_entities_id" 23 + }), 13 24 entity_set: one(entity_sets, { 14 25 fields: [entities.set], 15 26 references: [entity_sets.id] 16 27 }), 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 + export const factsRelations = relations(facts, ({one}) => ({ 40 + entity: one(entities, { 41 + fields: [facts.entity], 42 + references: [entities.id] 43 + }), 44 + })); 45 + 46 + export const identitiesRelations = relations(identities, ({one, many}) => ({ 47 + permission_token: one(permission_tokens, { 48 + fields: [identities.home_page], 49 + references: [permission_tokens.id] 50 + }), 51 + email_auth_tokens: many(email_auth_tokens), 52 + custom_domains: many(custom_domains), 53 + permission_token_on_homepages: many(permission_token_on_homepage), 54 + })); 55 + 27 56 export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ 57 + identities: many(identities), 28 58 entity: one(entities, { 29 59 fields: [permission_tokens.root_entity], 30 60 references: [entities.id] 31 61 }), 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 - export const identitiesRelations = relations(identities, ({one, many}) => ({ 45 - permission_token: one(permission_tokens, { 46 - fields: [identities.home_page], 47 - references: [permission_tokens.id] 48 - }), 49 - email_auth_tokens: many(email_auth_tokens), 50 - custom_domains: many(custom_domains), 51 - permission_token_on_homepages: many(permission_token_on_homepage), 52 - })); 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 - export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ 73 - identity: one(identities, { 74 - fields: [custom_domains.identity], 75 - references: [identities.email] 91 + export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ 92 + entity: one(entities, { 93 + fields: [phone_rsvps_to_entity.entity], 94 + references: [entities.id] 76 95 }), 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 - export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ 98 - entity: one(entities, { 99 - fields: [phone_rsvps_to_entity.entity], 100 - references: [entities.id] 115 + export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ 116 + custom_domain_routes: many(custom_domain_routes), 117 + identity: one(identities, { 118 + fields: [custom_domains.identity], 119 + references: [identities.email] 101 120 }), 102 121 })); 103 122
+38 -30
drizzle/schema.ts
··· 1 - import { pgTable, foreignKey, pgEnum, uuid, text, jsonb, timestamp, bigint, unique, boolean, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" 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 + export const poll_votes_on_entity = pgTable("poll_votes_on_entity", { 18 + id: uuid("id").defaultRandom().primaryKey().notNull(), 19 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 20 + poll_entity: uuid("poll_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 21 + option_entity: uuid("option_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 22 + voter_token: uuid("voter_token").notNull(), 23 + }); 24 + 25 + export const entities = pgTable("entities", { 26 + id: uuid("id").primaryKey().notNull(), 27 + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 28 + set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), 29 + }); 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 - export const entities = pgTable("entities", { 36 - id: uuid("id").primaryKey().notNull(), 37 - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 38 - set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), 39 - }); 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 - export const permission_tokens = pgTable("permission_tokens", { 47 - id: uuid("id").defaultRandom().primaryKey().notNull(), 48 - root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 49 - }); 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 + }); 65 + 66 + export const permission_tokens = pgTable("permission_tokens", { 67 + id: uuid("id").defaultRandom().primaryKey().notNull(), 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 - export const custom_domains = pgTable("custom_domains", { 92 - domain: text("domain").primaryKey().notNull(), 93 - identity: text("identity").default('').notNull().references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), 94 - confirmed: boolean("confirmed").notNull(), 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 + phone_number: text("phone_number").notNull(), 102 + country_code: text("country_code").notNull(), 103 + status: rsvp_status("status").notNull(), 104 + id: uuid("id").defaultRandom().primaryKey().notNull(), 105 + entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 106 + name: text("name").default('').notNull(), 107 + plus_ones: smallint("plus_ones").default(0).notNull(), 108 + }, 109 + (table) => { 110 + return { 111 + unique_phone_number_entities: uniqueIndex("unique_phone_number_entities").on(table.phone_number, table.entity), 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 - 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 + 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 - export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { 129 + export const custom_domains = pgTable("custom_domains", { 130 + domain: text("domain").primaryKey().notNull(), 131 + identity: text("identity").default('').notNull().references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), 132 + confirmed: boolean("confirmed").notNull(), 113 133 created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), 114 - phone_number: text("phone_number").notNull(), 115 - country_code: text("country_code").notNull(), 116 - status: rsvp_status("status").notNull(), 117 - id: uuid("id").defaultRandom().primaryKey().notNull(), 118 - entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), 119 - name: text("name").default('').notNull(), 120 - plus_ones: smallint("plus_ones").default(0).notNull(), 121 - }, 122 - (table) => { 123 - return { 124 - unique_phone_number_entities: uniqueIndex("unique_phone_number_entities").on(table.phone_number, table.entity), 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 + const removePollOption: Mutation<{ 595 + optionEntity: string; 596 + }> = async (args, ctx) => { 597 + ctx.deleteEntity(args.optionEntity); 598 + }; 599 + 594 600 export const mutations = { 595 601 retractAttribute, 596 602 addBlock, ··· 611 617 createDraft, 612 618 createEntity, 613 619 addPollOption, 620 + removePollOption, 614 621 };