Add spaces concept (#1776)

This commit is contained in:
Luke Vella 2025-06-15 11:48:51 +02:00 committed by GitHub
parent 92a72dde60
commit 04fcc0350f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 389 additions and 93 deletions

View file

@ -5,6 +5,10 @@ import type { NextRequest } from "next/server";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import type {
SubscriptionCheckoutMetadata,
SubscriptionMetadata,
} from "@/features/subscription/schema";
import { auth } from "@/next-auth"; import { auth } from "@/next-auth";
const inputSchema = z.object({ const inputSchema = z.object({
@ -46,11 +50,26 @@ export async function POST(request: NextRequest) {
active: true, active: true,
}, },
}, },
spaces: {
select: {
id: true,
},
},
}, },
}); });
if (!user) { if (!user) {
return new NextResponse(null, { status: 404 }); return NextResponse.json(
{ error: `User with ID ${userSession.user.id} not found` },
{ status: 404 },
);
}
if (user.spaces.length === 0) {
return NextResponse.json(
{ error: `Space with owner ID ${userSession.user.id} not found` },
{ status: 404 },
);
} }
let customerId = user.customerId; let customerId = user.customerId;
@ -83,6 +102,8 @@ export async function POST(request: NextRequest) {
const proPricingData = await getProPricing(); const proPricingData = await getProPricing();
const spaceId = user.spaces[0].id;
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
success_url: absoluteUrl( success_url: absoluteUrl(
return_path ?? "/api/stripe/portal?session_id={CHECKOUT_SESSION_ID}", return_path ?? "/api/stripe/portal?session_id={CHECKOUT_SESSION_ID}",
@ -101,11 +122,13 @@ export async function POST(request: NextRequest) {
}, },
metadata: { metadata: {
userId: userSession.user.id, userId: userSession.user.id,
}, spaceId,
} satisfies SubscriptionCheckoutMetadata,
subscription_data: { subscription_data: {
metadata: { metadata: {
userId: userSession.user.id, userId: userSession.user.id,
}, spaceId,
} satisfies SubscriptionMetadata,
}, },
line_items: [ line_items: [
{ {

View file

@ -1,11 +1,12 @@
import type { Stripe } from "@rallly/billing"; import type { Stripe } from "@rallly/billing";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { getDefaultSpace, getSpace } from "@/features/spaces/queries";
import { subscriptionMetadataSchema } from "@/features/subscription/schema";
import { import {
getExpandedSubscription, getExpandedSubscription,
getSubscriptionDetails, getSubscriptionDetails,
isSubscriptionActive, isSubscriptionActive,
subscriptionMetadataSchema,
toDate, toDate,
} from "../utils"; } from "../utils";
@ -29,13 +30,46 @@ export async function onCustomerSubscriptionCreated(event: Stripe.Event) {
// Check if user already has a subscription // Check if user already has a subscription
const existingUser = await prisma.user.findUnique({ const existingUser = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
include: { subscription: true }, select: {
subscription: true,
},
}); });
if (!existingUser) { if (!existingUser) {
throw new Error(`User with ID ${userId} not found`); throw new Error(`User with ID ${userId} not found`);
} }
let spaceId: string;
// The space should be in the metadata,
// but if it's not, we fallback to the default space.
// This is temporary while we haven't run a data migration
// to add the spaceId to the metadata for all existing subscriptions
if (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) {
throw new Error(
`Space with ID ${res.data.spaceId} does not belong to user ${userId}`,
);
}
spaceId = space.id;
} else {
// TODO: Remove this fallback once all subscriptions have
// a spaceId in their metadata
const space = await getDefaultSpace({ ownerId: userId });
if (!space) {
throw new Error(`Default space with owner ID ${userId} not found`);
}
spaceId = space.id;
}
// If user already has a subscription, update it or replace it // If user already has a subscription, update it or replace it
if (existingUser.subscription) { if (existingUser.subscription) {
// Update the existing subscription with new data // Update the existing subscription with new data
@ -53,18 +87,15 @@ export async function onCustomerSubscriptionCreated(event: Stripe.Event) {
periodStart: toDate(subscription.current_period_start), periodStart: toDate(subscription.current_period_start),
periodEnd: toDate(subscription.current_period_end), periodEnd: toDate(subscription.current_period_end),
cancelAtPeriodEnd: subscription.cancel_at_period_end, cancelAtPeriodEnd: subscription.cancel_at_period_end,
spaceId,
}, },
}); });
} else { } else {
// Create a new subscription for the user // Create a new subscription for the user
await prisma.user.update({ await prisma.subscription.create({
where: {
id: userId,
},
data: { data: {
subscription: {
create: {
id: subscription.id, id: subscription.id,
userId,
active: isActive, active: isActive,
priceId, priceId,
currency, currency,
@ -75,8 +106,7 @@ export async function onCustomerSubscriptionCreated(event: Stripe.Event) {
periodStart: toDate(subscription.current_period_start), periodStart: toDate(subscription.current_period_start),
periodEnd: toDate(subscription.current_period_end), periodEnd: toDate(subscription.current_period_end),
cancelAtPeriodEnd: subscription.cancel_at_period_end, cancelAtPeriodEnd: subscription.cancel_at_period_end,
}, spaceId,
},
}, },
}); });
} }

View file

@ -2,11 +2,11 @@ import type { Stripe } from "@rallly/billing";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { posthog } from "@rallly/posthog/server"; import { posthog } from "@rallly/posthog/server";
import { subscriptionMetadataSchema } from "@/features/subscription/schema";
import { import {
getExpandedSubscription, getExpandedSubscription,
getSubscriptionDetails, getSubscriptionDetails,
isSubscriptionActive, isSubscriptionActive,
subscriptionMetadataSchema,
toDate, toDate,
} from "../utils"; } from "../utils";

View file

@ -2,11 +2,6 @@ import type { Stripe } from "@rallly/billing";
import { stripe } from "@rallly/billing"; import { stripe } from "@rallly/billing";
import type { Prisma } from "@rallly/database"; import type { Prisma } from "@rallly/database";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
import { z } from "zod";
export const subscriptionMetadataSchema = z.object({
userId: z.string(),
});
export function toDate(date: number) { export function toDate(date: number) {
return new Date(date * 1000); return new Date(date * 1000);

View file

@ -37,5 +37,27 @@ export function CustomPrismaAdapter(options: {
}, },
}); });
}, },
createUser: async (user) => {
const newUser = await prisma.user.create({
data: {
name: user.name ?? "Unknown",
email: user.email,
emailVerified: user.emailVerified,
image: user.image,
timeZone: user.timeZone,
weekStart: user.weekStart,
timeFormat: user.timeFormat,
locale: user.locale,
role: "user",
spaces: {
create: {
name: "Personal",
},
},
},
});
return newUser;
},
} as Adapter; } as Adapter;
} }

View file

@ -1,3 +1,4 @@
import { getDefaultSpace } from "@/features/spaces/queries";
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";
@ -6,13 +7,9 @@ export const mergeGuestsIntoUser = async (
userId: string, userId: string,
guestIds: string[], guestIds: string[],
) => { ) => {
const count = await prisma.user.count({ const space = await getDefaultSpace({ ownerId: userId });
where: {
id: userId,
},
});
if (count === 0) { if (!space) {
console.warn(`User ${userId} not found`); console.warn(`User ${userId} not found`);
return; return;
} }
@ -29,6 +26,7 @@ export const mergeGuestsIntoUser = async (
data: { data: {
guestId: null, guestId: null,
userId: userId, userId: userId,
spaceId: space.id,
}, },
}), }),

View file

@ -1,3 +1,4 @@
import { getDefaultSpace } 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";
@ -34,3 +35,21 @@ export const requireAdmin = cache(async () => {
return user; return user;
}); });
export const getActiveSpace = cache(async () => {
const session = await auth();
if (!session?.user?.id) {
return null;
}
const user = await getUser(session.user.id);
if (!user) {
return null;
}
const space = await getDefaultSpace({ ownerId: user.id });
return space;
});

View file

@ -0,0 +1,38 @@
import { prisma } from "@rallly/database";
export async function listSpaces({ ownerId }: { ownerId: string }) {
const spaces = await prisma.space.findMany({
where: {
ownerId,
},
});
return spaces;
}
export async function getDefaultSpace({ ownerId }: { ownerId: string }) {
const space = await prisma.space.findFirst({
where: {
ownerId,
},
orderBy: {
createdAt: "asc",
},
});
if (!space) {
throw new Error(`Space with owner ID ${ownerId} not found`);
}
return space;
}
export async function getSpace({ id }: { id: string }) {
const space = await prisma.space.findUnique({
where: {
id,
},
});
return space;
}

View file

@ -2,8 +2,16 @@ import { z } from "zod";
export const subscriptionCheckoutMetadataSchema = z.object({ export const subscriptionCheckoutMetadataSchema = z.object({
userId: z.string(), userId: z.string(),
spaceId: z.string().optional(),
}); });
export type SubscriptionCheckoutMetadata = z.infer< export type SubscriptionCheckoutMetadata = z.infer<
typeof subscriptionCheckoutMetadataSchema typeof subscriptionCheckoutMetadataSchema
>; >;
export const subscriptionMetadataSchema = z.object({
userId: z.string(),
spaceId: z.string().optional(),
});
export type SubscriptionMetadata = z.infer<typeof subscriptionMetadataSchema>;

View file

@ -133,6 +133,11 @@ export const auth = router({
timeFormat: input.timeFormat, timeFormat: input.timeFormat,
weekStart: input.weekStart, weekStart: input.weekStart,
locale: input.locale, locale: input.locale,
spaces: {
create: {
name: "Personal",
},
},
}, },
}); });

View file

@ -12,6 +12,7 @@ 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 { getTimeZoneAbbreviation } from "../../utils/date"; import { getTimeZoneAbbreviation } from "../../utils/date";
import { import {
createRateLimitMiddleware, createRateLimitMiddleware,
@ -180,6 +181,8 @@ export const polls = router({
const participantUrlId = nanoid(); const participantUrlId = nanoid();
const pollId = nanoid(); const pollId = nanoid();
const space = await getActiveSpace();
const poll = await prisma.poll.create({ const poll = await prisma.poll.create({
select: { select: {
adminUrlId: true, adminUrlId: true,
@ -228,6 +231,7 @@ export const polls = router({
disableComments: input.disableComments, disableComments: input.disableComments,
hideScores: input.hideScores, hideScores: input.hideScores,
requireParticipantEmail: input.requireParticipantEmail, requireParticipantEmail: input.requireParticipantEmail,
spaceId: space?.id,
}, },
}); });
@ -551,6 +555,7 @@ export const polls = router({
title: true, title: true,
location: true, location: true,
description: true, description: true,
spaceId: true,
user: { user: {
select: { select: {
name: true, name: true,
@ -620,6 +625,13 @@ export const polls = router({
eventStart = eventStart.utc(); eventStart = eventStart.utc();
} }
if (!poll.spaceId) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Poll has no space",
});
}
await prisma.poll.update({ await prisma.poll.update({
where: { where: {
id: input.pollId, id: input.pollId,
@ -635,6 +647,7 @@ export const polls = router({
location: poll.location, location: poll.location,
timeZone: poll.timeZone, timeZone: poll.timeZone,
userId: ctx.user.id, userId: ctx.user.id,
spaceId: poll.spaceId,
allDay: option.duration === 0, allDay: option.duration === 0,
status: "confirmed", status: "confirmed",
invites: { invites: {

View file

@ -0,0 +1,34 @@
/*
Warnings:
- A unique constraint covering the columns `[space_id]` on the table `subscriptions` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "polls" ADD COLUMN "space_id" TEXT;
-- AlterTable
ALTER TABLE "subscriptions" ADD COLUMN "space_id" TEXT;
-- CreateTable
CREATE TABLE "spaces" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"owner_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "spaces_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "subscriptions_space_id_key" ON "subscriptions"("space_id");
-- AddForeignKey
ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_space_id_fkey" FOREIGN KEY ("space_id") REFERENCES "spaces"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "polls" ADD CONSTRAINT "polls_space_id_fkey" FOREIGN KEY ("space_id") REFERENCES "spaces"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "spaces" ADD CONSTRAINT "spaces_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,17 @@
INSERT INTO spaces (id, name, owner_id, created_at, updated_at)
SELECT gen_random_uuid(), 'Personal', id, NOW(), NOW()
FROM users
WHERE NOT EXISTS (
SELECT 1 FROM spaces WHERE spaces.owner_id = users.id
) ON CONFLICT DO NOTHING;
-- Set space_id for polls
UPDATE polls
SET space_id = spaces.id
FROM spaces
WHERE polls.user_id = spaces.owner_id AND polls.space_id IS NULL;
UPDATE subscriptions
SET space_id = spaces.id
FROM spaces
WHERE subscriptions.user_id = spaces.owner_id AND subscriptions.space_id IS NULL;

View file

@ -0,0 +1,15 @@
/*
Warnings:
- Added the required column `space_id` to the `scheduled_events` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "scheduled_events" ADD COLUMN "space_id" TEXT;
-- AddForeignKey
ALTER TABLE "scheduled_events" ADD CONSTRAINT "scheduled_events_space_id_fkey" FOREIGN KEY ("space_id") REFERENCES "spaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
UPDATE "scheduled_events" SET "space_id" = (SELECT "id" FROM "spaces" WHERE "owner_id" = "scheduled_events"."user_id" LIMIT 1);
ALTER TABLE "scheduled_events" ALTER COLUMN "space_id" SET NOT NULL;

View file

@ -11,8 +11,10 @@ model Subscription {
periodEnd DateTime @map("period_end") periodEnd DateTime @map("period_end")
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end") cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
userId String @unique @map("user_id") userId String @unique @map("user_id")
spaceId String? @unique @map("space_id")
user User @relation("UserToSubscription", fields: [userId], references: [id], onDelete: Cascade) user User @relation("UserToSubscription", fields: [userId], references: [id], onDelete: Cascade)
space Space? @relation("SpaceToSubscription", fields: [spaceId], references: [id], onDelete: SetNull)
@@map("subscriptions") @@map("subscriptions")
} }

View file

@ -18,6 +18,7 @@ enum ScheduledEventInviteStatus {
model ScheduledEvent { model ScheduledEvent {
id String @id @default(cuid()) id String @id @default(cuid())
userId String @map("user_id") userId String @map("user_id")
spaceId String @map("space_id")
title String title String
description String? description String?
location String? location String?
@ -31,6 +32,7 @@ model ScheduledEvent {
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
rescheduledDates RescheduledEventDate[] rescheduledDates RescheduledEventDate[]
invites ScheduledEventInvite[] invites ScheduledEventInvite[]
polls Poll[] polls Poll[]

View file

@ -46,6 +46,8 @@ model Poll {
comments Comment[] comments Comment[]
votes Vote[] votes Vote[]
views PollView[] views PollView[]
space Space? @relation(fields: [spaceId], references: [id], onDelete: SetNull)
spaceId String? @map("space_id")
@@index([guestId]) @@index([guestId])
@@map("polls") @@map("polls")

View file

@ -57,6 +57,9 @@ model User {
participants Participant[] participants Participant[]
paymentMethods PaymentMethod[] paymentMethods PaymentMethod[]
subscription Subscription? @relation("UserToSubscription") subscription Subscription? @relation("UserToSubscription")
spaces Space[] @relation("UserSpaces")
pollViews PollView[] pollViews PollView[]
scheduledEvents ScheduledEvent[] scheduledEvents ScheduledEvent[]
scheduledEventInvites ScheduledEventInvite[] scheduledEventInvites ScheduledEventInvite[]
@ -64,6 +67,21 @@ model User {
@@map("users") @@map("users")
} }
model Space {
id String @id @default(uuid())
name String
ownerId String @map("owner_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
owner User @relation("UserSpaces", fields: [ownerId], references: [id], onDelete: Cascade)
polls Poll[]
scheduledEvents ScheduledEvent[]
subscription Subscription? @relation("SpaceToSubscription")
@@map("spaces")
}
model VerificationToken { model VerificationToken {
identifier String @db.Citext identifier String @db.Citext
token String @unique token String @unique

View file

@ -41,7 +41,13 @@ function generateDescription() {
return faker.helpers.arrayElement(descriptions); return faker.helpers.arrayElement(descriptions);
} }
async function createPollForUser(userId: string) { async function createPollForUser({
userId,
spaceId,
}: {
userId: string;
spaceId: string;
}) {
const duration = 60 * randInt(8); const duration = 60 * randInt(8);
let cursor = dayjs().add(randInt(30), "day").second(0).minute(0); let cursor = dayjs().add(randInt(30), "day").second(0).minute(0);
const numberOfOptions = randInt(5, 2); // Reduced for realism const numberOfOptions = randInt(5, 2); // Reduced for realism
@ -62,6 +68,11 @@ async function createPollForUser(userId: string) {
id: userId, id: userId,
}, },
}, },
space: {
connect: {
id: spaceId,
},
},
status: faker.helpers.arrayElement(["live", "paused", "finalized"]), status: faker.helpers.arrayElement(["live", "paused", "finalized"]),
timeZone: duration !== 0 ? "Europe/London" : undefined, timeZone: duration !== 0 ? "Europe/London" : undefined,
options: { options: {
@ -108,8 +119,18 @@ async function createPollForUser(userId: string) {
export async function seedPolls(userId: string) { export async function seedPolls(userId: string) {
console.info("Seeding polls..."); console.info("Seeding polls...");
const space = await prisma.space.findFirst({
where: {
ownerId: userId,
},
});
if (!space) {
throw new Error(`No space found for user ${userId}`);
}
const pollPromises = Array.from({ length: 20 }).map(() => const pollPromises = Array.from({ length: 20 }).map(() =>
createPollForUser(userId), createPollForUser({ userId, spaceId: space.id }),
); );
await Promise.all(pollPromises); await Promise.all(pollPromises);

View file

@ -1,6 +1,6 @@
import { faker } from "@faker-js/faker"; import { faker } from "@faker-js/faker";
import type { ScheduledEventInviteStatus } from "@prisma/client"; import type { ScheduledEventInviteStatus } from "@prisma/client";
import { type Prisma, ScheduledEventStatus } from "@prisma/client"; // Ensure Prisma is imported import { ScheduledEventStatus } from "@prisma/client"; // Ensure Prisma is imported
import dayjs from "dayjs"; import dayjs from "dayjs";
import { prisma } from "@rallly/database"; import { prisma } from "@rallly/database";
@ -54,7 +54,13 @@ function generateEventDetails() {
}; };
} }
async function createScheduledEventForUser(userId: string) { async function createScheduledEventForUser({
userId,
spaceId,
}: {
userId: string;
spaceId: string;
}) {
const { title, description } = generateEventDetails(); const { title, description } = generateEventDetails();
const isAllDay = Math.random() < 0.3; // ~30% chance of being all-day const isAllDay = Math.random() < 0.3; // ~30% chance of being all-day
@ -101,14 +107,20 @@ async function createScheduledEventForUser(userId: string) {
]); ]);
const timeZone = faker.address.timeZone(); const timeZone = faker.address.timeZone();
const data: Prisma.ScheduledEventCreateInput = { await prisma.scheduledEvent.create({
data: {
title, title,
description, description,
start: startTime, // Use correct model field name 'start' start: startTime, // Use correct model field name 'start'
end: endTime, // Use correct model field name 'end' end: endTime, // Use correct model field name 'end'
timeZone, timeZone,
status, // Assign the randomly selected valid status status, // Assign the randomly selected valid status
user: { connect: { id: userId } }, // Connect to existing user user: {
connect: { id: userId },
}, // Connect to existing user
space: {
connect: { id: spaceId },
},
allDay: isAllDay, allDay: isAllDay,
location: faker.datatype.boolean() location: faker.datatype.boolean()
? faker.address.streetAddress() ? faker.address.streetAddress()
@ -127,15 +139,24 @@ async function createScheduledEventForUser(userId: string) {
]), ]),
})), })),
}, },
}; },
});
await prisma.scheduledEvent.create({ data });
} }
export async function seedScheduledEvents(userId: string) { export async function seedScheduledEvents(userId: string) {
console.info("Seeding scheduled events..."); console.info("Seeding scheduled events...");
const space = await prisma.space.findFirst({
where: {
ownerId: userId,
},
});
if (!space) {
throw new Error(`No space found for user ${userId}`);
}
const eventPromises = Array.from({ length: 15 }).map((_, i) => const eventPromises = Array.from({ length: 15 }).map((_, i) =>
createScheduledEventForUser(userId), createScheduledEventForUser({ userId, spaceId: space.id }),
); );
await Promise.all(eventPromises); await Promise.all(eventPromises);

View file

@ -11,6 +11,12 @@ export async function seedUsers() {
name: "Dev User", name: "Dev User",
email: "dev@rallly.co", email: "dev@rallly.co",
timeZone: "America/New_York", timeZone: "America/New_York",
spaces: {
create: {
id: "space-1",
name: "Personal",
},
},
}, },
}); });
@ -21,6 +27,12 @@ export async function seedUsers() {
id: "pro-user", id: "pro-user",
name: "Pro User", name: "Pro User",
email: "dev+pro@rallly.co", email: "dev+pro@rallly.co",
spaces: {
create: {
id: "space-2",
name: "Personal",
},
},
subscription: { subscription: {
create: { create: {
id: "sub_123", id: "sub_123",
@ -32,6 +44,7 @@ export async function seedUsers() {
priceId: "price_123", priceId: "price_123",
periodStart: new Date(), periodStart: new Date(),
periodEnd: dayjs().add(1, "month").toDate(), periodEnd: dayjs().add(1, "month").toDate(),
spaceId: "space-2",
}, },
}, },
}, },