mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-23 03:07:25 +02:00
🗃️ Store active space for user (#1807)
This commit is contained in:
parent
d93baeafd9
commit
d672eb1012
15 changed files with 196 additions and 94 deletions
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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}`,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 }],
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
|
@ -49,6 +49,15 @@ export async function createUser({
|
|||
},
|
||||
});
|
||||
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
activeSpaceId: space.id,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
|
@ -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")
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue