🗃️ Store active space for user (#1807)

This commit is contained in:
Luke Vella 2025-07-11 10:35:28 +01:00 committed by GitHub
parent d93baeafd9
commit d672eb1012
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 196 additions and 94 deletions

View file

@ -10,7 +10,7 @@
"editor.defaultFormatter": "biomejs.biome" "editor.defaultFormatter": "biomejs.biome"
}, },
"typescript.tsdk": "node_modules/typescript/lib", "typescript.tsdk": "node_modules/typescript/lib",
"typescript.preferences.importModuleSpecifier": "shortest", "typescript.preferences.importModuleSpecifier": "non-relative",
"cSpell.words": ["Rallly", "Vella"], "cSpell.words": ["Rallly", "Vella"],
"jestrunner.codeLensSelector": "", "jestrunner.codeLensSelector": "",
"vitest.filesWatcherInclude": "**/*.test.ts", "vitest.filesWatcherInclude": "**/*.test.ts",

View file

@ -25,7 +25,6 @@ import { getPolls } from "@/features/poll/api/get-polls";
import { PollList, PollListItem } from "@/features/poll/components/poll-list"; import { PollList, PollListItem } from "@/features/poll/components/poll-list";
import { getTranslation } from "@/i18n/server"; import { getTranslation } from "@/i18n/server";
import { getActiveSpace } from "@/auth/queries";
import { SearchInput } from "../../../components/search-input"; import { SearchInput } from "../../../components/search-input";
import { PollsTabbedView } from "./polls-tabbed-view"; import { PollsTabbedView } from "./polls-tabbed-view";
import { DEFAULT_PAGE_SIZE, searchParamsSchema } from "./schema"; import { DEFAULT_PAGE_SIZE, searchParamsSchema } from "./schema";
@ -42,9 +41,8 @@ async function loadData({
pageSize?: number; pageSize?: number;
q?: string; q?: string;
}) { }) {
const space = await getActiveSpace();
const [{ total, data: polls }] = await Promise.all([ const [{ total, data: polls }] = await Promise.all([
getPolls({ spaceId: space.id, status, page, pageSize, q }), getPolls({ status, page, pageSize, q }),
]); ]);
return { return {

View file

@ -41,6 +41,10 @@ export async function onCustomerSubscriptionCreated(event: Stripe.Event) {
const space = await getSpace({ id: res.data.spaceId }); 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) { if (space.ownerId !== userId) {
throw new Error( throw new Error(
`Space with ID ${res.data.spaceId} does not belong to user ${userId}`, `Space with ID ${res.data.spaceId} does not belong to user ${userId}`,

View file

@ -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 { prisma } from "@rallly/database";
import { posthog } from "@rallly/posthog/server"; import { posthog } from "@rallly/posthog/server";
import * as Sentry from "@sentry/nextjs"; 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 ( export const mergeGuestsIntoUser = async (
userId: string, userId: string,
guestIds: string[], guestIds: string[],
) => { ) => {
const space = await getDefaultSpace({ ownerId: userId }); const space = await getActiveSpaceForUser({ userId });
if (!space) { if (!space) {
console.warn(`User ${userId} not found`); console.error(`User ${userId} has no active space or default space`);
return; return;
} }

View file

@ -1,10 +1,13 @@
import { defineAbilityFor } from "@/features/ability-manager"; 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 { getUser } from "@/features/user/queries";
import { auth } from "@/next-auth"; import { auth } from "@/next-auth";
import { notFound, redirect } from "next/navigation"; import { notFound, redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
/**
* @deprecated - Use requireUserAbility() instead
*/
export const requireUser = cache(async () => { export const requireUser = cache(async () => {
const session = await auth(); const session = await auth();
if (!session?.user) { if (!session?.user) {
@ -25,7 +28,7 @@ export const isInitialAdmin = cache((email: string) => {
}); });
export const requireAdmin = cache(async () => { export const requireAdmin = cache(async () => {
const user = await requireUser(); const { user } = await requireUserAbility();
if (user.role !== "admin") { if (user.role !== "admin") {
if (isInitialAdmin(user.email)) { if (isInitialAdmin(user.email)) {
@ -38,9 +41,21 @@ export const requireAdmin = cache(async () => {
}); });
export const getActiveSpace = 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 () => { export const requireUserAbility = cache(async () => {

View file

@ -96,5 +96,16 @@ export const defineAbilityFor = (
can("cancel", "ScheduledEvent", { userId: user.id }); can("cancel", "ScheduledEvent", { userId: user.id });
can("read", "Space", {
ownerId: user.id,
});
can("read", "Space", {
members: {
some: {
userId: user.id,
},
},
});
return build(); return build();
}; };

View file

@ -1,8 +1,8 @@
import { getActiveSpace } from "@/auth/queries";
import type { PollStatus, Prisma } from "@rallly/database"; import type { PollStatus, Prisma } from "@rallly/database";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
type PollFilters = { type PollFilters = {
spaceId: string;
status?: PollStatus; status?: PollStatus;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
@ -10,15 +10,16 @@ type PollFilters = {
}; };
export async function getPolls({ export async function getPolls({
spaceId,
status, status,
page = 1, page = 1,
pageSize = 10, pageSize = 10,
q, q,
}: PollFilters) { }: PollFilters) {
const space = await getActiveSpace();
// Build the where clause based on filters // Build the where clause based on filters
const where: Prisma.PollWhereInput = { const where: Prisma.PollWhereInput = {
spaceId, spaceId: space.id,
status, status,
deleted: false, deleted: false,
}; };

View file

@ -1,19 +1,26 @@
import { requireUserAbility } from "@/auth/queries";
import { accessibleBy } from "@casl/prisma";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { cache } from "react";
export async function listSpaces({ ownerId }: { ownerId: string }) { export const listSpaces = cache(async () => {
const spaces = await prisma.space.findMany({ const { ability } = await requireUserAbility();
where: { const spaces = await prisma.spaceMember.findMany({
ownerId, 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({ const space = await prisma.space.findFirst({
where: { where: {
ownerId, ownerId: user.id,
}, },
orderBy: { orderBy: {
createdAt: "asc", createdAt: "asc",
@ -21,16 +28,17 @@ export async function getDefaultSpace({ ownerId }: { ownerId: string }) {
}); });
if (!space) { 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; return space;
} });
export async function getSpace({ id }: { id: string }) { export const getSpace = cache(async ({ id }: { id: string }) => {
return await prisma.space.findUniqueOrThrow({ const { ability } = await requireUserAbility();
return await prisma.space.findFirst({
where: { where: {
id, AND: [accessibleBy(ability).Space, { id }],
}, },
}); });
} });

View file

@ -49,6 +49,15 @@ export async function createUser({
}, },
}); });
await tx.user.update({
where: {
id: user.id,
},
data: {
activeSpaceId: space.id,
},
});
return user; return user;
}); });
} }

View file

@ -18,6 +18,7 @@ export const getUser = cache(async (userId: string) => {
timeFormat: true, timeFormat: true,
weekStart: true, weekStart: true,
role: true, role: true,
activeSpaceId: true,
subscription: { subscription: {
select: { select: {
active: true, active: true,
@ -31,15 +32,12 @@ export const getUser = cache(async (userId: string) => {
} }
return { return {
id: user.id, ...user,
name: user.name,
email: user.email,
image: user.image ?? undefined, image: user.image ?? undefined,
locale: user.locale ?? undefined, locale: user.locale ?? undefined,
timeZone: user.timeZone ?? undefined, timeZone: user.timeZone ?? undefined,
timeFormat: user.timeFormat ?? undefined, timeFormat: user.timeFormat ?? undefined,
weekStart: user.weekStart ?? undefined, weekStart: user.weekStart ?? undefined,
role: user.role,
isPro: Boolean(isSelfHosted || user.subscription?.active), isPro: Boolean(isSelfHosted || user.subscription?.active),
}; };
}); });

View file

@ -12,8 +12,8 @@ import { z } from "zod";
import { moderateContent } from "@/features/moderation"; import { moderateContent } from "@/features/moderation";
import { getEmailClient } from "@/utils/emails"; import { getEmailClient } from "@/utils/emails";
import { getActiveSpace } from "@/auth/queries";
import { formatEventDateTime } from "@/features/scheduled-event/utils"; import { formatEventDateTime } from "@/features/scheduled-event/utils";
import { getDefaultSpace } from "@/features/spaces/queries";
import { import {
createRateLimitMiddleware, createRateLimitMiddleware,
possiblyPublicProcedure, possiblyPublicProcedure,
@ -183,7 +183,7 @@ export const polls = router({
let spaceId: string | undefined; let spaceId: string | undefined;
if (!ctx.user.isGuest) { if (!ctx.user.isGuest) {
const space = await getDefaultSpace({ ownerId: ctx.user.id }); const space = await getActiveSpace();
spaceId = space.id; spaceId = space.id;
} }

View file

@ -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
);

View file

@ -10,7 +10,8 @@ model Space {
scheduledEvents ScheduledEvent[] scheduledEvents ScheduledEvent[]
subscription Subscription? @relation("SpaceToSubscription") subscription Subscription? @relation("SpaceToSubscription")
members SpaceMember[] members SpaceMember[]
activeForUsers User[] @relation("UserActiveSpace")
@@index([ownerId], type: Hash) @@index([ownerId], type: Hash)
@@map("spaces") @@map("spaces")

View file

@ -49,6 +49,7 @@ model User {
bannedAt DateTime? @map("banned_at") bannedAt DateTime? @map("banned_at")
banReason String? @map("ban_reason") banReason String? @map("ban_reason")
role UserRole @default(user) role UserRole @default(user)
activeSpaceId String? @map("active_space_id")
comments Comment[] comments Comment[]
polls Poll[] polls Poll[]
@ -57,6 +58,7 @@ model User {
participants Participant[] participants Participant[]
paymentMethods PaymentMethod[] paymentMethods PaymentMethod[]
subscription Subscription? @relation("UserToSubscription") subscription Subscription? @relation("UserToSubscription")
activeSpace Space? @relation("UserActiveSpace", fields: [activeSpaceId], references: [id], onDelete: SetNull)
spaces Space[] @relation("UserSpaces") spaces Space[] @relation("UserSpaces")
memberOf SpaceMember[] memberOf SpaceMember[]

View file

@ -1,68 +1,72 @@
import { prisma } from "@rallly/database"; 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() { export async function seedUsers() {
console.info("Seeding users..."); console.info("Seeding users...");
const freeUser = await prisma.user.upsert({ const freeUser = await createUser({
where: { email: "dev@rallly.co" }, id: "free-user",
update: {}, name: "Dev User",
create: { email: "dev@rallly.co",
id: "free-user", timeZone: "America/New_York",
name: "Dev User", space: {
email: "dev@rallly.co", id: "space-1",
timeZone: "America/New_York", name: "Personal",
spaces: {
create: {
id: "space-1",
name: "Personal",
},
},
}, },
}); });
await prisma.spaceMember.create({ const proUser = await createUser({
data: { id: "pro-user",
spaceId: "space-1", name: "Pro User",
userId: "free-user", email: "dev+pro@rallly.co",
role: "OWNER", timeZone: "America/New_York",
}, space: {
}); id: "space-2",
name: "Personal",
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",
}, },
}); });