From 210c3688f34a5ccec479067e0b0348889a56185b Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Tue, 24 Dec 2024 11:45:04 +0100 Subject: [PATCH 1/6] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Keep=20guest=20ids=20i?= =?UTF-8?q?n=20separate=20column?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/[locale]/(admin)/polls/user-polls.tsx | 6 +- .../src/components/discussion/discussion.tsx | 10 +--- apps/web/src/components/poll/desktop-poll.tsx | 1 + .../poll/desktop-poll/participant-row.tsx | 5 +- apps/web/src/components/user-provider.tsx | 10 +++- apps/web/src/contexts/permissions.tsx | 13 +++-- apps/web/src/trpc/routers/polls.ts | 16 +++++- apps/web/src/trpc/routers/polls/comments.ts | 4 +- .../src/trpc/routers/polls/participants.ts | 2 +- apps/web/src/utils/permissions.ts | 10 ++++ .../migration.sql | 57 +++++++++++++++++++ packages/database/prisma/schema.prisma | 8 ++- 12 files changed, 118 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/utils/permissions.ts create mode 100644 packages/database/prisma/migrations/20241224103150_guest_id_column/migration.sql diff --git a/apps/web/src/app/[locale]/(admin)/polls/user-polls.tsx b/apps/web/src/app/[locale]/(admin)/polls/user-polls.tsx index 9ca958016b8..3de0b1008e8 100644 --- a/apps/web/src/app/[locale]/(admin)/polls/user-polls.tsx +++ b/apps/web/src/app/[locale]/(admin)/polls/user-polls.tsx @@ -182,7 +182,11 @@ function PollsListView({ status: PollStatus; title: string; createdAt: Date; - userId: string; + user: { + id: string; + name: string; + } | null; + guestId?: string | null; participants: { id: string; name: string; diff --git a/apps/web/src/components/discussion/discussion.tsx b/apps/web/src/components/discussion/discussion.tsx index a98c963c778..80e863e67fd 100644 --- a/apps/web/src/components/discussion/discussion.tsx +++ b/apps/web/src/components/discussion/discussion.tsx @@ -34,7 +34,6 @@ import { OptimizedAvatarImage } from "@/components/optimized-avatar-image"; import { Participant, ParticipantName } from "@/components/participant"; import { useParticipants } from "@/components/participants-provider"; import { Trans } from "@/components/trans"; -import { usePermissions } from "@/contexts/permissions"; import { usePoll } from "@/contexts/poll"; import { useRole } from "@/contexts/role"; import { trpc } from "@/trpc/client"; @@ -73,7 +72,6 @@ function NewCommentForm({ const posthog = usePostHog(); - const session = useUser(); const { register, reset, control, handleSubmit, formState } = @@ -162,9 +160,7 @@ function DiscussionInner() { const pollId = poll.id; - const { data: comments } = trpc.polls.comments.list.useQuery( - { pollId }, - ); + const { data: comments } = trpc.polls.comments.list.useQuery({ pollId }); const posthog = usePostHog(); const queryClient = trpc.useUtils(); @@ -187,7 +183,6 @@ function DiscussionInner() { const [isWriting, setIsWriting] = React.useState(false); const role = useRole(); - const { isUser } = usePermissions(); if (!comments) { return null; @@ -205,8 +200,7 @@ function DiscussionInner() {
{comments.map((comment) => { - const canDelete = - role === "admin" || (comment.userId && isUser(comment.userId)); + const canDelete = role === "admin" || session.ownsObject(comment); return (
diff --git a/apps/web/src/components/poll/desktop-poll.tsx b/apps/web/src/components/poll/desktop-poll.tsx index 0deffa5dce3..797ffa8976a 100644 --- a/apps/web/src/components/poll/desktop-poll.tsx +++ b/apps/web/src/components/poll/desktop-poll.tsx @@ -308,6 +308,7 @@ const DesktopPoll: React.FunctionComponent = () => { id: participant.id, name: participant.name, userId: participant.userId ?? undefined, + guestId: participant.guestId ?? undefined, email: participant.email ?? undefined, votes: participant.votes, }} diff --git a/apps/web/src/components/poll/desktop-poll/participant-row.tsx b/apps/web/src/components/poll/desktop-poll/participant-row.tsx index 6dd08079503..ec46c72f0dc 100644 --- a/apps/web/src/components/poll/desktop-poll/participant-row.tsx +++ b/apps/web/src/components/poll/desktop-poll/participant-row.tsx @@ -23,6 +23,7 @@ export interface ParticipantRowProps { id: string; name: string; userId?: string; + guestId?: string; email?: string; votes: Vote[]; }; @@ -105,10 +106,10 @@ const ParticipantRow: React.FunctionComponent = ({ className, onChangeEditMode, }) => { - const { user, ownsObject } = useUser(); + const { ownsObject } = useUser(); const { getVote, optionIds } = usePoll(); - const isYou = user && ownsObject(participant) ? true : false; + const isYou = ownsObject(participant) ? true : false; const { canEditParticipant } = usePermissions(); const canEdit = canEditParticipant(participant.id); diff --git a/apps/web/src/components/user-provider.tsx b/apps/web/src/components/user-provider.tsx index e8161a478fb..a047ea73fad 100644 --- a/apps/web/src/components/user-provider.tsx +++ b/apps/web/src/components/user-provider.tsx @@ -9,6 +9,7 @@ import { useSubscription } from "@/contexts/plan"; import { PreferencesProvider } from "@/contexts/preferences"; import { useTranslation } from "@/i18n/client"; import { trpc } from "@/trpc/client"; +import { isOwner } from "@/utils/permissions"; import { useRequiredContext } from "./use-required-context"; @@ -28,7 +29,10 @@ type UserData = { export const UserContext = React.createContext<{ user: UserData; refresh: (data?: Record) => Promise; - ownsObject: (obj: { userId?: string | null }) => boolean; + ownsObject: (obj: { + userId?: string | null; + guestId?: string | null; + }) => boolean; } | null>(null); export const useUser = () => { @@ -101,8 +105,8 @@ export const UserProvider = (props: { children?: React.ReactNode }) => { locale: user.locale ?? i18n.language, }, refresh: session.update, - ownsObject: ({ userId }) => { - return userId ? [user.id].includes(userId) : false; + ownsObject: (resource) => { + return isOwner(resource, { id: user.id, isGuest }); }, }} > diff --git a/apps/web/src/contexts/permissions.tsx b/apps/web/src/contexts/permissions.tsx index af31e2bbaaa..99fa1f6020d 100644 --- a/apps/web/src/contexts/permissions.tsx +++ b/apps/web/src/contexts/permissions.tsx @@ -26,7 +26,7 @@ export const PermissionProvider = ({ export const usePermissions = () => { const poll = usePoll(); const context = React.useContext(PermissionsContext); - const { user } = useUser(); + const { user, ownsObject } = useUser(); const role = useRole(); const { participants } = useParticipants(); const isClosed = poll.closed === true || poll.event !== null; @@ -45,10 +45,15 @@ export const usePermissions = () => { (participant) => participant.id === participantId, ); + if (!participant) { + return false; + } + if ( - participant && - (participant.userId === user.id || - (context.userId && participant.userId === context.userId)) + ownsObject(participant) || + (context.userId && + (participant.userId === context.userId || + participant.guestId === context.userId)) ) { return true; } diff --git a/apps/web/src/trpc/routers/polls.ts b/apps/web/src/trpc/routers/polls.ts index 8344c70e32e..96d3b649062 100644 --- a/apps/web/src/trpc/routers/polls.ts +++ b/apps/web/src/trpc/routers/polls.ts @@ -73,7 +73,9 @@ export const polls = router({ const { cursor, limit, status } = input; const polls = await prisma.poll.findMany({ where: { - userId: ctx.user.id, + ...(ctx.user.isGuest + ? { guestId: ctx.user.id } + : { userId: ctx.user.id }), deletedAt: null, status: status === "all" ? undefined : status, }, @@ -94,7 +96,13 @@ export const polls = router({ timeZone: true, createdAt: true, status: true, - userId: true, + guestId: true, + user: { + select: { + id: true, + name: true, + }, + }, participants: { where: { deletedAt: null, @@ -164,7 +172,9 @@ export const polls = router({ description: input.description, adminUrlId: adminToken, participantUrlId, - userId: ctx.user.id, + ...(ctx.user.isGuest + ? { guestId: ctx.user.id } + : { userId: ctx.user.id }), watchers: !ctx.user.isGuest ? { create: { diff --git a/apps/web/src/trpc/routers/polls/comments.ts b/apps/web/src/trpc/routers/polls/comments.ts index 56982cb045e..b52931d335d 100644 --- a/apps/web/src/trpc/routers/polls/comments.ts +++ b/apps/web/src/trpc/routers/polls/comments.ts @@ -41,7 +41,9 @@ export const comments = router({ content, pollId, authorName, - userId: ctx.user.id, + ...(ctx.user.isGuest + ? { guestId: ctx.user.id } + : { userId: ctx.user.id }), }, select: { id: true, diff --git a/apps/web/src/trpc/routers/polls/participants.ts b/apps/web/src/trpc/routers/polls/participants.ts index 1b2f5066d2f..ed082687a7f 100644 --- a/apps/web/src/trpc/routers/polls/participants.ts +++ b/apps/web/src/trpc/routers/polls/participants.ts @@ -95,7 +95,7 @@ export const participants = router({ pollId: pollId, name: name, email, - userId: user.id, + ...(user.isGuest ? { guestId: user.id } : { userId: user.id }), locale: user.locale ?? undefined, }, include: { diff --git a/apps/web/src/utils/permissions.ts b/apps/web/src/utils/permissions.ts new file mode 100644 index 00000000000..32ba9381f53 --- /dev/null +++ b/apps/web/src/utils/permissions.ts @@ -0,0 +1,10 @@ +export function isOwner( + resource: { userId?: string | null; guestId?: string | null }, + user: { id: string; isGuest: boolean }, +) { + if (user.isGuest) { + return resource.guestId === user.id; + } + + return resource.userId === user.id; +} diff --git a/packages/database/prisma/migrations/20241224103150_guest_id_column/migration.sql b/packages/database/prisma/migrations/20241224103150_guest_id_column/migration.sql new file mode 100644 index 00000000000..aa61a986a51 --- /dev/null +++ b/packages/database/prisma/migrations/20241224103150_guest_id_column/migration.sql @@ -0,0 +1,57 @@ +-- AlterTable +ALTER TABLE "comments" ADD COLUMN "guest_id" TEXT; + +-- AlterTable +ALTER TABLE "participants" ADD COLUMN "guest_id" TEXT; + +-- AlterTable +ALTER TABLE "polls" ADD COLUMN "guest_id" TEXT, +ALTER COLUMN "user_id" DROP NOT NULL; + +-- CreateIndex +CREATE INDEX "comments_guest_id_idx" ON "comments" USING HASH ("guest_id"); + +-- CreateIndex +CREATE INDEX "participants_guest_id_idx" ON "participants" USING HASH ("guest_id"); + +-- CreateIndex +CREATE INDEX "polls_guest_id_idx" ON "polls" USING HASH ("guest_id"); + +-- Migrate polls +UPDATE "polls" p +SET + "guest_id" = CASE + WHEN NOT EXISTS (SELECT 1 FROM "users" u WHERE u.id = p.user_id) THEN p.user_id + ELSE NULL + END, + "user_id" = CASE + WHEN NOT EXISTS (SELECT 1 FROM "users" u WHERE u.id = p.user_id) THEN NULL + ELSE p.user_id + END +WHERE p.user_id IS NOT NULL; + +-- Migrate participants +UPDATE "participants" p +SET + "guest_id" = CASE + WHEN NOT EXISTS (SELECT 1 FROM "users" u WHERE u.id = p.user_id) THEN p.user_id + ELSE NULL + END, + "user_id" = CASE + WHEN NOT EXISTS (SELECT 1 FROM "users" u WHERE u.id = p.user_id) THEN NULL + ELSE p.user_id + END +WHERE p.user_id IS NOT NULL; + +-- Migrate comments +UPDATE "comments" c +SET + "guest_id" = CASE + WHEN NOT EXISTS (SELECT 1 FROM "users" u WHERE u.id = c.user_id) THEN c.user_id + ELSE NULL + END, + "user_id" = CASE + WHEN NOT EXISTS (SELECT 1 FROM "users" u WHERE u.id = c.user_id) THEN NULL + ELSE c.user_id + END +WHERE c.user_id IS NOT NULL; diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index 3a6aba9d732..3fa579bc36f 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -124,7 +124,8 @@ model Poll { title String description String? location String? - userId String @map("user_id") + userId String? @map("user_id") + guestId String? @map("guest_id") timeZone String? @map("time_zone") closed Boolean @default(false) // @deprecated status PollStatus @default(live) @@ -147,6 +148,7 @@ model Poll { comments Comment[] @@index([userId], type: Hash) + @@index([guestId], type: Hash) @@map("polls") } @@ -184,6 +186,7 @@ model Participant { name String email String? userId String? @map("user_id") + guestId String? @map("guest_id") poll Poll @relation(fields: [pollId], references: [id]) pollId String @map("poll_id") votes Vote[] @@ -194,6 +197,7 @@ model Participant { deletedAt DateTime? @map("deleted_at") @@index([pollId], type: Hash) + @@index([guestId], type: Hash) @@map("participants") } @@ -241,10 +245,12 @@ model Comment { authorName String @map("author_name") user User? @relation(fields: [userId], references: [id]) userId String? @map("user_id") + guestId String? @map("guest_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime? @updatedAt @map("updated_at") @@index([userId], type: Hash) + @@index([guestId], type: Hash) @@index([pollId], type: Hash) @@map("comments") } From 02c9673f66aa2e67a3c7506d701a2aad21c4809a Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Tue, 24 Dec 2024 13:22:32 +0100 Subject: [PATCH 2/6] Update merge guest user script --- apps/web/src/auth/merge-user.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/auth/merge-user.ts b/apps/web/src/auth/merge-user.ts index cf560fd436a..cd677d76d49 100644 --- a/apps/web/src/auth/merge-user.ts +++ b/apps/web/src/auth/merge-user.ts @@ -6,33 +6,36 @@ export const mergeGuestsIntoUser = async ( ) => { await prisma.poll.updateMany({ where: { - userId: { + guestId: { in: guestIds, }, }, data: { + guestId: null, userId: userId, }, }); await prisma.participant.updateMany({ where: { - userId: { + guestId: { in: guestIds, }, }, data: { + guestId: null, userId: userId, }, }); await prisma.comment.updateMany({ where: { - userId: { + guestId: { in: guestIds, }, }, data: { + guestId: null, userId: userId, }, }); From 3e4379ec8dc2808e6c0d77bec1516c78b6920f15 Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Tue, 24 Dec 2024 13:27:23 +0100 Subject: [PATCH 3/6] Update poll owner --- apps/web/src/trpc/routers/polls.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/src/trpc/routers/polls.ts b/apps/web/src/trpc/routers/polls.ts index 96d3b649062..ece01bdd07a 100644 --- a/apps/web/src/trpc/routers/polls.ts +++ b/apps/web/src/trpc/routers/polls.ts @@ -462,6 +462,7 @@ export const polls = router({ }, user: true, userId: true, + guestId: true, deleted: true, event: { select: { @@ -486,7 +487,11 @@ export const polls = router({ } const inviteLink = shortUrl(`/invite/${res.id}`); - if (ctx.user.id === res.userId || res.adminUrlId === input.adminToken) { + const isOwner = ctx.user.isGuest + ? ctx.user.id === res.guestId + : ctx.user.id === res.userId; + + if (isOwner || res.adminUrlId === input.adminToken) { return { ...res, inviteLink }; } else { return { ...res, adminUrlId: "", inviteLink }; From 1dc19835854117bf15ec1874252476bb1100056e Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Tue, 24 Dec 2024 13:30:42 +0100 Subject: [PATCH 4/6] Peform user merge in transaction --- apps/web/src/auth/merge-user.ts | 68 +++++++++++++++++---------------- 1 file changed, 36 insertions(+), 32 deletions(-) diff --git a/apps/web/src/auth/merge-user.ts b/apps/web/src/auth/merge-user.ts index cd677d76d49..9262f89a088 100644 --- a/apps/web/src/auth/merge-user.ts +++ b/apps/web/src/auth/merge-user.ts @@ -4,39 +4,43 @@ export const mergeGuestsIntoUser = async ( userId: string, guestIds: string[], ) => { - await prisma.poll.updateMany({ - where: { - guestId: { - in: guestIds, - }, - }, - data: { - guestId: null, - userId: userId, - }, - }); + return await prisma.$transaction(async (tx) => { + await Promise.all([ + tx.poll.updateMany({ + where: { + guestId: { + in: guestIds, + }, + }, + data: { + guestId: null, + userId: userId, + }, + }), - await prisma.participant.updateMany({ - where: { - guestId: { - in: guestIds, - }, - }, - data: { - guestId: null, - userId: userId, - }, - }); + tx.participant.updateMany({ + where: { + guestId: { + in: guestIds, + }, + }, + data: { + guestId: null, + userId: userId, + }, + }), - await prisma.comment.updateMany({ - where: { - guestId: { - in: guestIds, - }, - }, - data: { - guestId: null, - userId: userId, - }, + tx.comment.updateMany({ + where: { + guestId: { + in: guestIds, + }, + }, + data: { + guestId: null, + userId: userId, + }, + }), + ]); }); }; From 515fa0c8e1f1365209259e7f8671ecbd6a92e652 Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Thu, 26 Dec 2024 12:24:09 +0100 Subject: [PATCH 5/6] Fix ownership --- apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx | 4 ++-- apps/web/src/contexts/permissions.tsx | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx b/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx index 04681d726ed..f8be656a7ed 100644 --- a/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx +++ b/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx @@ -16,8 +16,8 @@ import { usePoll } from "@/contexts/poll"; const GoToApp = () => { const poll = usePoll(); - const { user } = useUser(); - if (poll.userId !== user.id) { + const { ownsObject } = useUser(); + if (!ownsObject(poll)) { return null; } diff --git a/apps/web/src/contexts/permissions.tsx b/apps/web/src/contexts/permissions.tsx index 99fa1f6020d..8db5345db2e 100644 --- a/apps/web/src/contexts/permissions.tsx +++ b/apps/web/src/contexts/permissions.tsx @@ -37,7 +37,8 @@ export const usePermissions = () => { if (isClosed) { return false; } - if (role === "admin" && user.id === poll.userId) { + + if (role === "admin" && ownsObject(poll)) { return true; } From 4f4e7f99c60e3941fb9db96374be209094e9712d Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Thu, 26 Dec 2024 12:37:27 +0100 Subject: [PATCH 6/6] Get correct count on dashboard --- apps/web/src/trpc/routers/dashboard.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/trpc/routers/dashboard.ts b/apps/web/src/trpc/routers/dashboard.ts index 0eb790ada59..99c72d4be92 100644 --- a/apps/web/src/trpc/routers/dashboard.ts +++ b/apps/web/src/trpc/routers/dashboard.ts @@ -6,7 +6,13 @@ export const dashboard = router({ info: possiblyPublicProcedure.query(async ({ ctx }) => { const activePollCount = await prisma.poll.count({ where: { - userId: ctx.user.id, + ...(ctx.user.isGuest + ? { + guestId: ctx.user.id, + } + : { + userId: ctx.user.id, + }), status: "live", deleted: false, // TODO (Luke Vella) [2024-06-16]: We should add deleted/cancelled to the status enum },