mirror of
https://github.com/lukevella/rallly.git
synced 2025-07-23 19:27: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"
|
"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",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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}`,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 () => {
|
||||||
|
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
|
@ -49,6 +49,15 @@ export async function createUser({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await tx.user.update({
|
||||||
|
where: {
|
||||||
|
id: user.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
activeSpaceId: space.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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[]
|
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")
|
||||||
|
|
|
@ -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[]
|
||||||
|
|
|
@ -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",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue