diff --git a/.vscode/settings.json b/.vscode/settings.json index 44d49a02e..e1fc626ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,7 +10,7 @@ "editor.defaultFormatter": "biomejs.biome" }, "typescript.tsdk": "node_modules/typescript/lib", - "typescript.preferences.importModuleSpecifier": "shortest", + "typescript.preferences.importModuleSpecifier": "non-relative", "cSpell.words": ["Rallly", "Vella"], "jestrunner.codeLensSelector": "", "vitest.filesWatcherInclude": "**/*.test.ts", diff --git a/apps/web/src/app/[locale]/(space)/polls/page.tsx b/apps/web/src/app/[locale]/(space)/polls/page.tsx index 8bc3b7bf5..c55110370 100644 --- a/apps/web/src/app/[locale]/(space)/polls/page.tsx +++ b/apps/web/src/app/[locale]/(space)/polls/page.tsx @@ -25,7 +25,6 @@ import { getPolls } from "@/features/poll/api/get-polls"; import { PollList, PollListItem } from "@/features/poll/components/poll-list"; import { getTranslation } from "@/i18n/server"; -import { getActiveSpace } from "@/auth/queries"; import { SearchInput } from "../../../components/search-input"; import { PollsTabbedView } from "./polls-tabbed-view"; import { DEFAULT_PAGE_SIZE, searchParamsSchema } from "./schema"; @@ -42,9 +41,8 @@ async function loadData({ pageSize?: number; q?: string; }) { - const space = await getActiveSpace(); const [{ total, data: polls }] = await Promise.all([ - getPolls({ spaceId: space.id, status, page, pageSize, q }), + getPolls({ status, page, pageSize, q }), ]); return { diff --git a/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/created.ts b/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/created.ts index 4a78b9fd1..6d8fe5f2b 100644 --- a/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/created.ts +++ b/apps/web/src/app/api/stripe/webhook/handlers/customer-subscription/created.ts @@ -41,6 +41,10 @@ export async function onCustomerSubscriptionCreated(event: Stripe.Event) { const space = await getSpace({ id: res.data.spaceId }); + if (!space) { + throw new Error(`Space with ID ${res.data.spaceId} not found`); + } + if (space.ownerId !== userId) { throw new Error( `Space with ID ${res.data.spaceId} does not belong to user ${userId}`, diff --git a/apps/web/src/auth/helpers/merge-user.ts b/apps/web/src/auth/helpers/merge-user.ts index d51334f98..3a96e650d 100644 --- a/apps/web/src/auth/helpers/merge-user.ts +++ b/apps/web/src/auth/helpers/merge-user.ts @@ -1,16 +1,53 @@ -import { getDefaultSpace } from "@/features/spaces/queries"; +import { defineAbilityFor } from "@/features/ability-manager"; +import { getUser } from "@/features/user/queries"; +import { accessibleBy } from "@casl/prisma"; import { prisma } from "@rallly/database"; import { posthog } from "@rallly/posthog/server"; import * as Sentry from "@sentry/nextjs"; +const getActiveSpaceForUser = async ({ + userId, +}: { + userId: string; +}) => { + const user = await getUser(userId); + + if (!user) { + throw new Error(`User ${userId} not found`); + } + + const ability = defineAbilityFor(user); + + if (user.activeSpaceId) { + const space = await prisma.space.findFirst({ + where: { + AND: [accessibleBy(ability).Space, { id: user.activeSpaceId }], + }, + }); + + if (space) { + return space; + } + } + + return await prisma.space.findFirst({ + where: { + ownerId: user.id, + }, + orderBy: { + createdAt: "asc", + }, + }); +}; + export const mergeGuestsIntoUser = async ( userId: string, guestIds: string[], ) => { - const space = await getDefaultSpace({ ownerId: userId }); + const space = await getActiveSpaceForUser({ userId }); if (!space) { - console.warn(`User ${userId} not found`); + console.error(`User ${userId} has no active space or default space`); return; } diff --git a/apps/web/src/auth/queries.ts b/apps/web/src/auth/queries.ts index 6dcf296d9..98c57f49f 100644 --- a/apps/web/src/auth/queries.ts +++ b/apps/web/src/auth/queries.ts @@ -1,10 +1,13 @@ import { defineAbilityFor } from "@/features/ability-manager"; -import { getDefaultSpace } from "@/features/spaces/queries"; +import { getDefaultSpace, getSpace } from "@/features/spaces/queries"; import { getUser } from "@/features/user/queries"; import { auth } from "@/next-auth"; import { notFound, redirect } from "next/navigation"; import { cache } from "react"; +/** + * @deprecated - Use requireUserAbility() instead + */ export const requireUser = cache(async () => { const session = await auth(); if (!session?.user) { @@ -25,7 +28,7 @@ export const isInitialAdmin = cache((email: string) => { }); export const requireAdmin = cache(async () => { - const user = await requireUser(); + const { user } = await requireUserAbility(); if (user.role !== "admin") { if (isInitialAdmin(user.email)) { @@ -38,9 +41,21 @@ export const requireAdmin = cache(async () => { }); export const getActiveSpace = cache(async () => { - const user = await requireUser(); + const { user } = await requireUserAbility(); - return await getDefaultSpace({ ownerId: user.id }); + if (user.activeSpaceId) { + const activeSpace = await getSpace({ id: user.activeSpaceId }); + + if (activeSpace) { + return activeSpace; + } + + console.warn( + `User ${user.id} has an active space ID ${user.activeSpaceId} that does not exist or is no longer accessible`, + ); + } + + return await getDefaultSpace(); }); export const requireUserAbility = cache(async () => { diff --git a/apps/web/src/features/ability-manager/ability.ts b/apps/web/src/features/ability-manager/ability.ts index 8788334e4..79874b0f6 100644 --- a/apps/web/src/features/ability-manager/ability.ts +++ b/apps/web/src/features/ability-manager/ability.ts @@ -96,5 +96,16 @@ export const defineAbilityFor = ( can("cancel", "ScheduledEvent", { userId: user.id }); + can("read", "Space", { + ownerId: user.id, + }); + can("read", "Space", { + members: { + some: { + userId: user.id, + }, + }, + }); + return build(); }; diff --git a/apps/web/src/features/poll/api/get-polls.ts b/apps/web/src/features/poll/api/get-polls.ts index 37e74c21f..dcfc6bd9d 100644 --- a/apps/web/src/features/poll/api/get-polls.ts +++ b/apps/web/src/features/poll/api/get-polls.ts @@ -1,8 +1,8 @@ +import { getActiveSpace } from "@/auth/queries"; import type { PollStatus, Prisma } from "@rallly/database"; import { prisma } from "@rallly/database"; type PollFilters = { - spaceId: string; status?: PollStatus; page?: number; pageSize?: number; @@ -10,15 +10,16 @@ type PollFilters = { }; export async function getPolls({ - spaceId, status, page = 1, pageSize = 10, q, }: PollFilters) { + const space = await getActiveSpace(); + // Build the where clause based on filters const where: Prisma.PollWhereInput = { - spaceId, + spaceId: space.id, status, deleted: false, }; diff --git a/apps/web/src/features/spaces/queries.ts b/apps/web/src/features/spaces/queries.ts index b9997fd3b..cb6964b6e 100644 --- a/apps/web/src/features/spaces/queries.ts +++ b/apps/web/src/features/spaces/queries.ts @@ -1,19 +1,26 @@ +import { requireUserAbility } from "@/auth/queries"; +import { accessibleBy } from "@casl/prisma"; import { prisma } from "@rallly/database"; +import { cache } from "react"; -export async function listSpaces({ ownerId }: { ownerId: string }) { - const spaces = await prisma.space.findMany({ - where: { - ownerId, - }, +export const listSpaces = cache(async () => { + const { ability } = await requireUserAbility(); + const spaces = await prisma.spaceMember.findMany({ + where: accessibleBy(ability).SpaceMember, + include: { space: true }, }); - return spaces; -} + return spaces.map((spaceMember) => ({ + ...spaceMember.space, + role: spaceMember.role, + })); +}); -export async function getDefaultSpace({ ownerId }: { ownerId: string }) { +export const getDefaultSpace = cache(async () => { + const { user } = await requireUserAbility(); const space = await prisma.space.findFirst({ where: { - ownerId, + ownerId: user.id, }, orderBy: { createdAt: "asc", @@ -21,16 +28,17 @@ export async function getDefaultSpace({ ownerId }: { ownerId: string }) { }); if (!space) { - throw new Error(`Space with owner ID ${ownerId} not found`); + throw new Error(`Space with owner ID ${user.id} not found`); } return space; -} +}); -export async function getSpace({ id }: { id: string }) { - return await prisma.space.findUniqueOrThrow({ +export const getSpace = cache(async ({ id }: { id: string }) => { + const { ability } = await requireUserAbility(); + return await prisma.space.findFirst({ where: { - id, + AND: [accessibleBy(ability).Space, { id }], }, }); -} +}); diff --git a/apps/web/src/features/user/mutations.ts b/apps/web/src/features/user/mutations.ts index d4d323d3a..146982a5e 100644 --- a/apps/web/src/features/user/mutations.ts +++ b/apps/web/src/features/user/mutations.ts @@ -49,6 +49,15 @@ export async function createUser({ }, }); + await tx.user.update({ + where: { + id: user.id, + }, + data: { + activeSpaceId: space.id, + }, + }); + return user; }); } diff --git a/apps/web/src/features/user/queries.ts b/apps/web/src/features/user/queries.ts index 80a872c3b..99e4084bb 100644 --- a/apps/web/src/features/user/queries.ts +++ b/apps/web/src/features/user/queries.ts @@ -18,6 +18,7 @@ export const getUser = cache(async (userId: string) => { timeFormat: true, weekStart: true, role: true, + activeSpaceId: true, subscription: { select: { active: true, @@ -31,15 +32,12 @@ export const getUser = cache(async (userId: string) => { } return { - id: user.id, - name: user.name, - email: user.email, + ...user, image: user.image ?? undefined, locale: user.locale ?? undefined, timeZone: user.timeZone ?? undefined, timeFormat: user.timeFormat ?? undefined, weekStart: user.weekStart ?? undefined, - role: user.role, isPro: Boolean(isSelfHosted || user.subscription?.active), }; }); diff --git a/apps/web/src/trpc/routers/polls.ts b/apps/web/src/trpc/routers/polls.ts index e538ae7a8..b290945a5 100644 --- a/apps/web/src/trpc/routers/polls.ts +++ b/apps/web/src/trpc/routers/polls.ts @@ -12,8 +12,8 @@ import { z } from "zod"; import { moderateContent } from "@/features/moderation"; import { getEmailClient } from "@/utils/emails"; +import { getActiveSpace } from "@/auth/queries"; import { formatEventDateTime } from "@/features/scheduled-event/utils"; -import { getDefaultSpace } from "@/features/spaces/queries"; import { createRateLimitMiddleware, possiblyPublicProcedure, @@ -183,7 +183,7 @@ export const polls = router({ let spaceId: string | undefined; if (!ctx.user.isGuest) { - const space = await getDefaultSpace({ ownerId: ctx.user.id }); + const space = await getActiveSpace(); spaceId = space.id; } diff --git a/packages/database/prisma/migrations/20250710102819_add_active_space_to_user/migration.sql b/packages/database/prisma/migrations/20250710102819_add_active_space_to_user/migration.sql new file mode 100644 index 000000000..de38980e9 --- /dev/null +++ b/packages/database/prisma/migrations/20250710102819_add_active_space_to_user/migration.sql @@ -0,0 +1,14 @@ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "active_space_id" TEXT; + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_active_space_id_fkey" FOREIGN KEY ("active_space_id") REFERENCES "spaces"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- Set active_space_id to each user's default space (where they are the owner) +UPDATE "users" u +SET "active_space_id" = ( + SELECT s.id + FROM "spaces" s + WHERE s."owner_id" = u.id + LIMIT 1 +); diff --git a/packages/database/prisma/models/space.prisma b/packages/database/prisma/models/space.prisma index 647a66571..2a919413f 100644 --- a/packages/database/prisma/models/space.prisma +++ b/packages/database/prisma/models/space.prisma @@ -10,7 +10,8 @@ model Space { scheduledEvents ScheduledEvent[] subscription Subscription? @relation("SpaceToSubscription") - members SpaceMember[] + members SpaceMember[] + activeForUsers User[] @relation("UserActiveSpace") @@index([ownerId], type: Hash) @@map("spaces") diff --git a/packages/database/prisma/models/user.prisma b/packages/database/prisma/models/user.prisma index 7892fcd83..6a58c337d 100644 --- a/packages/database/prisma/models/user.prisma +++ b/packages/database/prisma/models/user.prisma @@ -49,6 +49,7 @@ model User { bannedAt DateTime? @map("banned_at") banReason String? @map("ban_reason") role UserRole @default(user) + activeSpaceId String? @map("active_space_id") comments Comment[] polls Poll[] @@ -57,6 +58,7 @@ model User { participants Participant[] paymentMethods PaymentMethod[] subscription Subscription? @relation("UserToSubscription") + activeSpace Space? @relation("UserActiveSpace", fields: [activeSpaceId], references: [id], onDelete: SetNull) spaces Space[] @relation("UserSpaces") memberOf SpaceMember[] diff --git a/packages/database/prisma/seed/users.ts b/packages/database/prisma/seed/users.ts index 82d2b3613..0fd3eb2ca 100644 --- a/packages/database/prisma/seed/users.ts +++ b/packages/database/prisma/seed/users.ts @@ -1,68 +1,72 @@ import { prisma } from "@rallly/database"; -import dayjs from "dayjs"; + +async function createUser({ + id, + name, + email, + timeZone, + space, +}: { + id: string; + name: string; + email: string; + timeZone: string; + space: { + id: string; + name: string; + }; +}) { + const user = await prisma.user.create({ + data: { + id, + name, + email, + timeZone, + spaces: { + create: space, + }, + }, + }); + + await prisma.spaceMember.create({ + data: { + spaceId: space.id, + userId: id, + role: "OWNER", + }, + }); + + await prisma.user.update({ + where: { id }, + data: { + activeSpaceId: space.id, + }, + }); + + return user; +} export async function seedUsers() { console.info("Seeding users..."); - const freeUser = await prisma.user.upsert({ - where: { email: "dev@rallly.co" }, - update: {}, - create: { - id: "free-user", - name: "Dev User", - email: "dev@rallly.co", - timeZone: "America/New_York", - spaces: { - create: { - id: "space-1", - name: "Personal", - }, - }, + const freeUser = await createUser({ + id: "free-user", + name: "Dev User", + email: "dev@rallly.co", + timeZone: "America/New_York", + space: { + id: "space-1", + name: "Personal", }, }); - await prisma.spaceMember.create({ - data: { - spaceId: "space-1", - userId: "free-user", - role: "OWNER", - }, - }); - - const proUser = await prisma.user.upsert({ - where: { email: "dev+pro@rallly.co" }, - update: {}, - create: { - id: "pro-user", - name: "Pro User", - email: "dev+pro@rallly.co", - spaces: { - create: { - id: "space-2", - name: "Personal", - }, - }, - subscription: { - create: { - id: "sub_123", - currency: "usd", - amount: 700, - interval: "month", - status: "active", - active: true, - priceId: "price_123", - periodStart: new Date(), - periodEnd: dayjs().add(1, "month").toDate(), - spaceId: "space-2", - }, - }, - }, - }); - - await prisma.spaceMember.create({ - data: { - spaceId: "space-2", - userId: "pro-user", - role: "OWNER", + const proUser = await createUser({ + id: "pro-user", + name: "Pro User", + email: "dev+pro@rallly.co", + timeZone: "America/New_York", + space: { + id: "space-2", + name: "Personal", }, });