🗃️ 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"
},
"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",

View file

@ -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 {

View file

@ -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}`,

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 { 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;
}

View file

@ -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 () => {

View file

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

View file

@ -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,
};

View file

@ -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 }],
},
});
}
});

View file

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

View file

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

View file

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

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[]
subscription Subscription? @relation("SpaceToSubscription")
members SpaceMember[]
members SpaceMember[]
activeForUsers User[] @relation("UserActiveSpace")
@@index([ownerId], type: Hash)
@@map("spaces")

View file

@ -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[]

View file

@ -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",
},
});