Files
stack/apps/api/src/workspaces/workspaces.service.ts
Jason Woltje 023949f1e0
Some checks failed
ci/woodpecker/push/api Pipeline failed
ci/woodpecker/push/orchestrator Pipeline failed
ci/woodpecker/push/web Pipeline failed
fix(api,web): separate workspace context from auth session (#534)
BetterAuth session responses contain only identity fields — workspace
context (workspaceId, currentWorkspaceId) was never returned, causing
"Workspace ID is required" on every guarded endpoint after login.

Add GET /api/workspaces endpoint (AuthGuard only, no WorkspaceGuard)
that returns user workspace memberships with auto-provisioning for
new users. Frontend auth-context now fetches workspaces after session
check and persists the default to localStorage. Race condition in
auto-provisioning is guarded by re-querying inside the transaction.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 09:04:15 -06:00

98 lines
2.8 KiB
TypeScript

import { Injectable, Logger } from "@nestjs/common";
import { WorkspaceMemberRole } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import type { WorkspaceResponseDto } from "./dto";
@Injectable()
export class WorkspacesService {
private readonly logger = new Logger(WorkspacesService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Get all workspaces the user is a member of.
*
* Auto-provisioning: if the user has no workspace memberships (e.g. fresh
* signup via BetterAuth), a default workspace is created atomically and
* returned. This is the only call site for workspace bootstrapping.
*/
async getUserWorkspaces(userId: string): Promise<WorkspaceResponseDto[]> {
const memberships = await this.prisma.workspaceMember.findMany({
where: { userId },
include: {
workspace: {
select: { id: true, name: true, ownerId: true, createdAt: true },
},
},
orderBy: { joinedAt: "asc" },
});
if (memberships.length > 0) {
return memberships.map((m) => ({
id: m.workspace.id,
name: m.workspace.name,
ownerId: m.workspace.ownerId,
role: m.role,
createdAt: m.workspace.createdAt,
}));
}
// Auto-provision a default workspace for new users.
// Re-query inside the transaction to guard against concurrent requests
// both seeing zero memberships and creating duplicate workspaces.
this.logger.log(`Auto-provisioning default workspace for user ${userId}`);
const workspace = await this.prisma.$transaction(async (tx) => {
const existing = await tx.workspaceMember.findFirst({
where: { userId },
include: {
workspace: {
select: { id: true, name: true, ownerId: true, createdAt: true },
},
},
});
if (existing) {
return { ...existing.workspace, alreadyExisted: true as const };
}
const created = await tx.workspace.create({
data: {
name: "My Workspace",
ownerId: userId,
settings: {},
},
});
await tx.workspaceMember.create({
data: {
workspaceId: created.id,
userId,
role: WorkspaceMemberRole.OWNER,
},
});
return { ...created, alreadyExisted: false as const };
});
if (workspace.alreadyExisted) {
return [
{
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
role: WorkspaceMemberRole.OWNER,
createdAt: workspace.createdAt,
},
];
}
return [
{
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
role: WorkspaceMemberRole.OWNER,
createdAt: workspace.createdAt,
},
];
}
}