From 75a873a5d1f11227fcce84005d00e8b7a73de4a2 Mon Sep 17 00:00:00 2001 From: celine Date: Wed, 19 Feb 2025 17:21:22 -0500 Subject: [PATCH] made the voting work --- actions/pollActions.ts | 47 +++++++++++++++++ components/Blocks/PollBlock.tsx | 81 ++++++++++++++---------------- components/PageSWRDataProvider.tsx | 9 ++++ drizzle/relations.ts | 71 ++++++++++++++++---------- drizzle/schema.ts | 68 ++++++++++++++----------- src/replicache/mutations.ts | 7 +++ 6 files changed, 184 insertions(+), 99 deletions(-) create mode 100644 actions/pollActions.ts diff --git a/actions/pollActions.ts b/actions/pollActions.ts new file mode 100644 index 00000000..23c5b0a4 --- /dev/null +++ b/actions/pollActions.ts @@ -0,0 +1,47 @@ +"use server"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { and, eq, inArray } from "drizzle-orm"; +import postgres from "postgres"; +import { entities, poll_votes_on_entity } from "drizzle/schema"; +import { cookies } from "next/headers"; +import { v7 } from "uuid"; + +export async function getPollData(entity_sets: string[]) { + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + let voter_token = cookies().get("poll_voter_token")?.value; + + const db = drizzle(client); + const polls = await db + .select() + .from(poll_votes_on_entity) + .innerJoin(entities, eq(entities.id, poll_votes_on_entity.poll_entity)) + .where(and(inArray(entities.set, entity_sets))); + console.log(polls); + return { polls, voter_token }; +} + +export async function voteOnPoll(poll_entity: string, option_entity: string) { + let voter_token = cookies().get("poll_voter_token")?.value; + if (!voter_token) { + voter_token = v7(); + cookies().set("poll_voter_token", voter_token); + } + const client = postgres(process.env.DB_URL as string, { idle_timeout: 5 }); + const db = drizzle(client); + + const pollVote = await db + .select() + .from(poll_votes_on_entity) + .where(eq(poll_votes_on_entity.poll_entity, poll_entity)); + + if ( + pollVote.find((v) => { + return v.voter_token === voter_token; + }) + ) + return; + + await db + .insert(poll_votes_on_entity) + .values({ option_entity, poll_entity, voter_token }); +} diff --git a/components/Blocks/PollBlock.tsx b/components/Blocks/PollBlock.tsx index 4e40442a..2358e5ee 100644 --- a/components/Blocks/PollBlock.tsx +++ b/components/Blocks/PollBlock.tsx @@ -9,6 +9,8 @@ import { useEntitySetContext } from "components/EntitySetProvider"; import { theme } from "tailwind.config"; import { useEntity, useReplicache } from "src/replicache"; import { v7 } from "uuid"; +import { usePollData } from "components/PageSWRDataProvider"; +import { voteOnPoll } from "actions/pollActions"; export const PollBlock = (props: BlockProps) => { let { rep } = useReplicache(); @@ -22,24 +24,33 @@ export const PollBlock = (props: BlockProps) => { ); let dataPollOptions = useEntity(props.entityID, "poll/options"); + let { data: pollData } = usePollData(); + console.log(pollData); - let [pollOptions, setPollOptions] = useState< - { value: string; votes: number }[] - >([ - { value: "hello", votes: 2 }, - { value: "hi", votes: 4 }, - ]); let [localPollOptionNames, setLocalPollOptionNames] = useState<{ [k: string]: string; }>({}); + let votes = + pollData?.polls.filter( + (v) => v.poll_votes_on_entity.poll_entity === props.entityID, + ) || []; + let totalVotes = votes.length; - let totalVotes = pollOptions.reduce((sum, option) => sum + option.votes, 0); + let votesByOptions = votes.reduce<{ [option: string]: number }>( + (results, vote) => { + results[vote.poll_votes_on_entity.option_entity] = + (results[vote.poll_votes_on_entity.option_entity] || 0) + 1; + return results; + }, + {}, + ); - let highestVotes = Math.max(...pollOptions.map((option) => option.votes)); - let winningIndexes = pollOptions.reduce( - (indexes, option, index) => { - if (option.votes === highestVotes) indexes.push(index); - return indexes; + let highestVotes = Math.max(...Object.values(votesByOptions)); + + let winningOptionEntities = Object.entries(votesByOptions).reduce( + (winningEntities, [entity, votes]) => { + if (votes === highestVotes) winningEntities.push(entity); + return winningEntities; }, [], ); @@ -60,42 +71,24 @@ export const PollBlock = (props: BlockProps) => { )} {/* Empty state if no options yet */} - {(pollOptions.every((option) => option.value === "") || - pollOptions.length === 0) && - pollState !== "editing" && ( -
- no options yet... -
- )} + {dataPollOptions.length === 0 && pollState !== "editing" && ( +
+ no options yet... +
+ )} {dataPollOptions.map((option, index) => ( { - setPollOptions((oldOptions) => { - let newOptions = [...oldOptions]; - newOptions[index] = { - value: oldOptions[index].value, - votes: newVotes, - }; - return newOptions; - }); - }} + votes={votesByOptions[option.data.value] || 0} totalVotes={totalVotes} - winner={winningIndexes.includes(index)} - removeOption={() => { - setPollOptions((oldOptions) => { - let newOptions = [...oldOptions]; - newOptions.splice(index, 1); - return newOptions; - }); - }} + winner={winningOptionEntities.includes(option.data.value)} /> ))} {!permissions.write ? null : pollState === "editing" ? ( @@ -133,6 +126,7 @@ export const PollBlock = (props: BlockProps) => { const PollOption = (props: { entityID: string; + pollEntity: string; localNameState: string | undefined; setLocalNameState: ( s: (s: { [k: string]: string }) => { [k: string]: string }, @@ -140,12 +134,12 @@ const PollOption = (props: { state: "editing" | "voting" | "results"; setState: (state: "editing" | "voting" | "results") => void; votes: number; - setVotes: (votes: number) => void; totalVotes: number; winner: boolean; - removeOption: () => void; }) => { let { rep } = useReplicache(); + let { mutate } = usePollData(); + let optionName = useEntity(props.entityID, "poll-option/name")?.data.value; useEffect(() => { props.setLocalNameState((s) => ({ @@ -172,7 +166,7 @@ const PollOption = (props: { onKeyDown={(e) => { if (e.key === "Backspace" && !e.currentTarget.value) { e.preventDefault(); - props.removeOption(); + rep?.mutate.removePollOption({ optionEntity: props.entityID }); } }} /> @@ -181,7 +175,7 @@ const PollOption = (props: { disabled={props.votes > 0} className="text-accent-contrast disabled:text-border" onMouseDown={() => { - props.removeOption(); + rep?.mutate.removePollOption({ optionEntity: props.entityID }); }} > @@ -193,7 +187,8 @@ const PollOption = (props: { className={`pollOption grow max-w-full`} onClick={() => { props.setState("results"); - props.setVotes(props.votes + 1); + voteOnPoll(props.pollEntity, props.entityID); + mutate(); }} > {optionName} diff --git a/components/PageSWRDataProvider.tsx b/components/PageSWRDataProvider.tsx index ba09c448..6ef2231b 100644 --- a/components/PageSWRDataProvider.tsx +++ b/components/PageSWRDataProvider.tsx @@ -4,6 +4,7 @@ import { SWRConfig } from "swr"; import { useReplicache } from "src/replicache"; import useSWR from "swr"; import { callRPC } from "app/api/rpc/client"; +import { getPollData } from "actions/pollActions"; export function PageSWRDataProvider(props: { leaflet_id: string; @@ -33,6 +34,14 @@ export function useRSVPData() { ), ); } +export function usePollData() { + let { permission_token } = useReplicache(); + return useSWR(`poll_data`, () => + getPollData( + permission_token.permission_token_rights.map((pr) => pr.entity_set), + ), + ); +} export function useLeafletDomains() { let { permission_token } = useReplicache(); return useSWR( diff --git a/drizzle/relations.ts b/drizzle/relations.ts index c0d11e54..9ff3dbd9 100644 --- a/drizzle/relations.ts +++ b/drizzle/relations.ts @@ -1,19 +1,31 @@ import { relations } from "drizzle-orm/relations"; -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"; +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"; -export const factsRelations = relations(facts, ({one}) => ({ - entity: one(entities, { - fields: [facts.entity], - references: [entities.id] +export const poll_votes_on_entityRelations = relations(poll_votes_on_entity, ({one}) => ({ + entity_option_entity: one(entities, { + fields: [poll_votes_on_entity.option_entity], + references: [entities.id], + relationName: "poll_votes_on_entity_option_entity_entities_id" + }), + entity_poll_entity: one(entities, { + fields: [poll_votes_on_entity.poll_entity], + references: [entities.id], + relationName: "poll_votes_on_entity_poll_entity_entities_id" }), })); export const entitiesRelations = relations(entities, ({one, many}) => ({ - facts: many(facts), + poll_votes_on_entities_option_entity: many(poll_votes_on_entity, { + relationName: "poll_votes_on_entity_option_entity_entities_id" + }), + poll_votes_on_entities_poll_entity: many(poll_votes_on_entity, { + relationName: "poll_votes_on_entity_poll_entity_entities_id" + }), entity_set: one(entity_sets, { fields: [entities.set], references: [entity_sets.id] }), + facts: many(facts), permission_tokens: many(permission_tokens), email_subscriptions_to_entities: many(email_subscriptions_to_entity), phone_rsvps_to_entities: many(phone_rsvps_to_entity), @@ -24,12 +36,29 @@ export const entity_setsRelations = relations(entity_sets, ({many}) => ({ permission_token_rights: many(permission_token_rights), })); +export const factsRelations = relations(facts, ({one}) => ({ + entity: one(entities, { + fields: [facts.entity], + references: [entities.id] + }), +})); + +export const identitiesRelations = relations(identities, ({one, many}) => ({ + permission_token: one(permission_tokens, { + fields: [identities.home_page], + references: [permission_tokens.id] + }), + email_auth_tokens: many(email_auth_tokens), + custom_domains: many(custom_domains), + permission_token_on_homepages: many(permission_token_on_homepage), +})); + export const permission_tokensRelations = relations(permission_tokens, ({one, many}) => ({ + identities: many(identities), entity: one(entities, { fields: [permission_tokens.root_entity], references: [entities.id] }), - identities: many(identities), email_subscriptions_to_entities: many(email_subscriptions_to_entity), custom_domain_routes_edit_permission_token: many(custom_domain_routes, { relationName: "custom_domain_routes_edit_permission_token_permission_tokens_id" @@ -41,16 +70,6 @@ export const permission_tokensRelations = relations(permission_tokens, ({one, ma permission_token_rights: many(permission_token_rights), })); -export const identitiesRelations = relations(identities, ({one, many}) => ({ - permission_token: one(permission_tokens, { - fields: [identities.home_page], - references: [permission_tokens.id] - }), - email_auth_tokens: many(email_auth_tokens), - custom_domains: many(custom_domains), - permission_token_on_homepages: many(permission_token_on_homepage), -})); - export const email_subscriptions_to_entityRelations = relations(email_subscriptions_to_entity, ({one}) => ({ entity: one(entities, { fields: [email_subscriptions_to_entity.entity], @@ -69,12 +88,11 @@ export const email_auth_tokensRelations = relations(email_auth_tokens, ({one}) = }), })); -export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ - identity: one(identities, { - fields: [custom_domains.identity], - references: [identities.email] +export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ + entity: one(entities, { + fields: [phone_rsvps_to_entity.entity], + references: [entities.id] }), - custom_domain_routes: many(custom_domain_routes), })); export const custom_domain_routesRelations = relations(custom_domain_routes, ({one}) => ({ @@ -94,10 +112,11 @@ export const custom_domain_routesRelations = relations(custom_domain_routes, ({o }), })); -export const phone_rsvps_to_entityRelations = relations(phone_rsvps_to_entity, ({one}) => ({ - entity: one(entities, { - fields: [phone_rsvps_to_entity.entity], - references: [entities.id] +export const custom_domainsRelations = relations(custom_domains, ({one, many}) => ({ + custom_domain_routes: many(custom_domain_routes), + identity: one(identities, { + fields: [custom_domains.identity], + references: [identities.email] }), })); diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 2f0a7028..94234ea4 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, foreignKey, pgEnum, uuid, text, jsonb, timestamp, bigint, unique, boolean, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" +import { pgTable, foreignKey, pgEnum, uuid, timestamp, text, jsonb, bigint, unique, boolean, uniqueIndex, smallint, primaryKey } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" export const aal_level = pgEnum("aal_level", ['aal1', 'aal2', 'aal3']) @@ -14,6 +14,20 @@ export const action = pgEnum("action", ['INSERT', 'UPDATE', 'DELETE', 'TRUNCATE' export const equality_op = pgEnum("equality_op", ['eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'in']) +export const poll_votes_on_entity = pgTable("poll_votes_on_entity", { + id: uuid("id").defaultRandom().primaryKey().notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + poll_entity: uuid("poll_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), + option_entity: uuid("option_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), + voter_token: uuid("voter_token").notNull(), +}); + +export const entities = pgTable("entities", { + id: uuid("id").primaryKey().notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), +}); + export const facts = pgTable("facts", { id: uuid("id").primaryKey().notNull(), entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "restrict" } ), @@ -32,22 +46,11 @@ export const replicache_clients = pgTable("replicache_clients", { last_mutation: bigint("last_mutation", { mode: "number" }).notNull(), }); -export const entities = pgTable("entities", { - id: uuid("id").primaryKey().notNull(), - created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - set: uuid("set").notNull().references(() => entity_sets.id, { onDelete: "cascade", onUpdate: "cascade" } ), -}); - export const entity_sets = pgTable("entity_sets", { id: uuid("id").defaultRandom().primaryKey().notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), }); -export const permission_tokens = pgTable("permission_tokens", { - id: uuid("id").defaultRandom().primaryKey().notNull(), - root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), -}); - export const identities = pgTable("identities", { id: uuid("id").defaultRandom().primaryKey().notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), @@ -60,6 +63,11 @@ export const identities = pgTable("identities", { } }); +export const permission_tokens = pgTable("permission_tokens", { + id: uuid("id").defaultRandom().primaryKey().notNull(), + root_entity: uuid("root_entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), +}); + export const email_subscriptions_to_entity = pgTable("email_subscriptions_to_entity", { id: uuid("id").defaultRandom().primaryKey().notNull(), entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade" } ), @@ -88,19 +96,28 @@ export const phone_number_auth_tokens = pgTable("phone_number_auth_tokens", { country_code: text("country_code").notNull(), }); -export const custom_domains = pgTable("custom_domains", { - domain: text("domain").primaryKey().notNull(), - identity: text("identity").default('').notNull().references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), - confirmed: boolean("confirmed").notNull(), +export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + phone_number: text("phone_number").notNull(), + country_code: text("country_code").notNull(), + status: rsvp_status("status").notNull(), + id: uuid("id").defaultRandom().primaryKey().notNull(), + entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), + name: text("name").default('').notNull(), + plus_ones: smallint("plus_ones").default(0).notNull(), +}, +(table) => { + return { + unique_phone_number_entities: uniqueIndex("unique_phone_number_entities").on(table.phone_number, table.entity), + } }); export const custom_domain_routes = pgTable("custom_domain_routes", { id: uuid("id").defaultRandom().primaryKey().notNull(), domain: text("domain").notNull().references(() => custom_domains.domain), route: text("route").notNull(), - view_permission_token: uuid("view_permission_token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), edit_permission_token: uuid("edit_permission_token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), + view_permission_token: uuid("view_permission_token").notNull().references(() => permission_tokens.id, { onDelete: "cascade", onUpdate: "cascade" } ), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), }, (table) => { @@ -109,20 +126,11 @@ export const custom_domain_routes = pgTable("custom_domain_routes", { } }); -export const phone_rsvps_to_entity = pgTable("phone_rsvps_to_entity", { +export const custom_domains = pgTable("custom_domains", { + domain: text("domain").primaryKey().notNull(), + identity: text("identity").default('').notNull().references(() => identities.email, { onDelete: "cascade", onUpdate: "cascade" } ), + confirmed: boolean("confirmed").notNull(), created_at: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), - phone_number: text("phone_number").notNull(), - country_code: text("country_code").notNull(), - status: rsvp_status("status").notNull(), - id: uuid("id").defaultRandom().primaryKey().notNull(), - entity: uuid("entity").notNull().references(() => entities.id, { onDelete: "cascade", onUpdate: "cascade" } ), - name: text("name").default('').notNull(), - plus_ones: smallint("plus_ones").default(0).notNull(), -}, -(table) => { - return { - unique_phone_number_entities: uniqueIndex("unique_phone_number_entities").on(table.phone_number, table.entity), - } }); export const permission_token_on_homepage = pgTable("permission_token_on_homepage", { diff --git a/src/replicache/mutations.ts b/src/replicache/mutations.ts index 59db41b9..f5159579 100644 --- a/src/replicache/mutations.ts +++ b/src/replicache/mutations.ts @@ -591,6 +591,12 @@ const addPollOption: Mutation<{ }); }; +const removePollOption: Mutation<{ + optionEntity: string; +}> = async (args, ctx) => { + ctx.deleteEntity(args.optionEntity); +}; + export const mutations = { retractAttribute, addBlock, @@ -611,4 +617,5 @@ export const mutations = { createDraft, createEntity, addPollOption, + removePollOption, }; -- 2.51.2