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 9ca958016..3de0b1008 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/app/[locale]/invite/[urlId]/invite-page.tsx b/apps/web/src/app/[locale]/invite/[urlId]/invite-page.tsx index 04681d726..f8be656a7 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/auth/merge-user.ts b/apps/web/src/auth/merge-user.ts index cf560fd43..9262f89a0 100644 --- a/apps/web/src/auth/merge-user.ts +++ b/apps/web/src/auth/merge-user.ts @@ -4,36 +4,43 @@ export const mergeGuestsIntoUser = async ( userId: string, guestIds: string[], ) => { - await prisma.poll.updateMany({ - where: { - userId: { - in: guestIds, - }, - }, - data: { - 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: { - userId: { - in: guestIds, - }, - }, - data: { - userId: userId, - }, - }); + tx.participant.updateMany({ + where: { + guestId: { + in: guestIds, + }, + }, + data: { + guestId: null, + userId: userId, + }, + }), - await prisma.comment.updateMany({ - where: { - userId: { - in: guestIds, - }, - }, - data: { - userId: userId, - }, + tx.comment.updateMany({ + where: { + guestId: { + in: guestIds, + }, + }, + data: { + guestId: null, + userId: userId, + }, + }), + ]); }); }; diff --git a/apps/web/src/components/discussion/discussion.tsx b/apps/web/src/components/discussion/discussion.tsx index a98c963c7..80e863e67 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 0deffa5dc..797ffa897 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 6dd080795..ec46c72f0 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 e8161a478..a047ea73f 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 af31e2bba..8db5345db 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; @@ -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; } @@ -45,10 +46,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/dashboard.ts b/apps/web/src/trpc/routers/dashboard.ts index 0eb790ada..99c72d4be 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 }, diff --git a/apps/web/src/trpc/routers/polls.ts b/apps/web/src/trpc/routers/polls.ts index 8344c70e3..ece01bdd0 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: { @@ -452,6 +462,7 @@ export const polls = router({ }, user: true, userId: true, + guestId: true, deleted: true, event: { select: { @@ -476,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 }; diff --git a/apps/web/src/trpc/routers/polls/comments.ts b/apps/web/src/trpc/routers/polls/comments.ts index 56982cb04..b52931d33 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 1b2f5066d..ed082687a 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 000000000..32ba9381f --- /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 000000000..aa61a986a --- /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 3a6aba9d7..3fa579bc3 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") }