fix(api,web): separate workspace context from auth session (#551)
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #551.
This commit is contained in:
1
apps/api/src/workspaces/dto/index.ts
Normal file
1
apps/api/src/workspaces/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { WorkspaceResponseDto } from "./workspace-response.dto";
|
||||
12
apps/api/src/workspaces/dto/workspace-response.dto.ts
Normal file
12
apps/api/src/workspaces/dto/workspace-response.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { WorkspaceMemberRole } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Response DTO for a workspace the authenticated user belongs to.
|
||||
*/
|
||||
export class WorkspaceResponseDto {
|
||||
id!: string;
|
||||
name!: string;
|
||||
ownerId!: string;
|
||||
role!: WorkspaceMemberRole;
|
||||
createdAt!: Date;
|
||||
}
|
||||
3
apps/api/src/workspaces/index.ts
Normal file
3
apps/api/src/workspaces/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { WorkspacesModule } from "./workspaces.module";
|
||||
export { WorkspacesService } from "./workspaces.service";
|
||||
export { WorkspacesController } from "./workspaces.controller";
|
||||
75
apps/api/src/workspaces/workspaces.controller.spec.ts
Normal file
75
apps/api/src/workspaces/workspaces.controller.spec.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { WorkspacesController } from "./workspaces.controller";
|
||||
import { WorkspacesService } from "./workspaces.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import type { AuthUser } from "@mosaic/shared";
|
||||
|
||||
describe("WorkspacesController", () => {
|
||||
let controller: WorkspacesController;
|
||||
let service: WorkspacesService;
|
||||
|
||||
const mockWorkspacesService = {
|
||||
getUserWorkspaces: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [WorkspacesController],
|
||||
providers: [
|
||||
{
|
||||
provide: WorkspacesService,
|
||||
useValue: mockWorkspacesService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<WorkspacesController>(WorkspacesController);
|
||||
service = module.get<WorkspacesService>(WorkspacesService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/workspaces", () => {
|
||||
it("should call service with authenticated user id", async () => {
|
||||
mockWorkspacesService.getUserWorkspaces.mockResolvedValueOnce([]);
|
||||
|
||||
await controller.getUserWorkspaces(mockUser);
|
||||
|
||||
expect(service.getUserWorkspaces).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("should return workspace list from service", async () => {
|
||||
const mockWorkspaces = [
|
||||
{
|
||||
id: "ws-1",
|
||||
name: "My Workspace",
|
||||
ownerId: "user-1",
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
},
|
||||
];
|
||||
mockWorkspacesService.getUserWorkspaces.mockResolvedValueOnce(mockWorkspaces);
|
||||
|
||||
const result = await controller.getUserWorkspaces(mockUser);
|
||||
|
||||
expect(result).toEqual(mockWorkspaces);
|
||||
});
|
||||
|
||||
it("should propagate service errors", async () => {
|
||||
mockWorkspacesService.getUserWorkspaces.mockRejectedValueOnce(new Error("Database error"));
|
||||
|
||||
await expect(controller.getUserWorkspaces(mockUser)).rejects.toThrow("Database error");
|
||||
});
|
||||
});
|
||||
});
|
||||
28
apps/api/src/workspaces/workspaces.controller.ts
Normal file
28
apps/api/src/workspaces/workspaces.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, UseGuards } from "@nestjs/common";
|
||||
import { WorkspacesService } from "./workspaces.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import type { AuthUser } from "@mosaic/shared";
|
||||
import type { WorkspaceResponseDto } from "./dto";
|
||||
|
||||
/**
|
||||
* User-scoped workspace operations.
|
||||
*
|
||||
* Intentionally does NOT use WorkspaceGuard — these routes operate across all
|
||||
* workspaces the user belongs to, not within a single workspace context.
|
||||
*/
|
||||
@Controller("workspaces")
|
||||
@UseGuards(AuthGuard)
|
||||
export class WorkspacesController {
|
||||
constructor(private readonly workspacesService: WorkspacesService) {}
|
||||
|
||||
/**
|
||||
* GET /api/workspaces
|
||||
* Returns workspaces the authenticated user is a member of.
|
||||
* Auto-provisions a default workspace if the user has none.
|
||||
*/
|
||||
@Get()
|
||||
async getUserWorkspaces(@CurrentUser() user: AuthUser): Promise<WorkspaceResponseDto[]> {
|
||||
return this.workspacesService.getUserWorkspaces(user.id);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/workspaces/workspaces.module.ts
Normal file
13
apps/api/src/workspaces/workspaces.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { WorkspacesController } from "./workspaces.controller";
|
||||
import { WorkspacesService } from "./workspaces.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [WorkspacesController],
|
||||
providers: [WorkspacesService],
|
||||
exports: [WorkspacesService],
|
||||
})
|
||||
export class WorkspacesModule {}
|
||||
229
apps/api/src/workspaces/workspaces.service.spec.ts
Normal file
229
apps/api/src/workspaces/workspaces.service.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { WorkspacesService } from "./workspaces.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
|
||||
describe("WorkspacesService", () => {
|
||||
let service: WorkspacesService;
|
||||
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
|
||||
const mockWorkspace = {
|
||||
id: mockWorkspaceId,
|
||||
name: "Test Workspace",
|
||||
ownerId: mockUserId,
|
||||
settings: {},
|
||||
matrixRoomId: null,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
const mockMembership = {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
workspace: {
|
||||
id: mockWorkspaceId,
|
||||
name: "Test Workspace",
|
||||
ownerId: mockUserId,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
},
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
workspaceMember: {
|
||||
findMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
WorkspacesService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<WorkspacesService>(WorkspacesService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getUserWorkspaces", () => {
|
||||
it("should return all workspaces user is a member of", async () => {
|
||||
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([mockMembership]);
|
||||
|
||||
const result = await service.getUserWorkspaces(mockUserId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockWorkspaceId,
|
||||
name: "Test Workspace",
|
||||
ownerId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
createdAt: mockMembership.workspace.createdAt,
|
||||
},
|
||||
]);
|
||||
expect(mockPrismaService.workspaceMember.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
include: {
|
||||
workspace: {
|
||||
select: { id: true, name: true, ownerId: true, createdAt: true },
|
||||
},
|
||||
},
|
||||
orderBy: { joinedAt: "asc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return multiple workspaces ordered by joinedAt", async () => {
|
||||
const secondWorkspace = {
|
||||
...mockMembership,
|
||||
workspaceId: "ws-2",
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
joinedAt: new Date("2026-02-01"),
|
||||
workspace: {
|
||||
id: "ws-2",
|
||||
name: "Second Workspace",
|
||||
ownerId: "other-user",
|
||||
createdAt: new Date("2026-02-01"),
|
||||
},
|
||||
};
|
||||
|
||||
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([
|
||||
mockMembership,
|
||||
secondWorkspace,
|
||||
]);
|
||||
|
||||
const result = await service.getUserWorkspaces(mockUserId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe(mockWorkspaceId);
|
||||
expect(result[1].id).toBe("ws-2");
|
||||
expect(result[1].role).toBe(WorkspaceMemberRole.MEMBER);
|
||||
});
|
||||
|
||||
it("should auto-provision a default workspace when user has no memberships", async () => {
|
||||
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]);
|
||||
mockPrismaService.$transaction.mockImplementationOnce(
|
||||
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) => {
|
||||
const txMock = {
|
||||
workspaceMember: {
|
||||
findFirst: vi.fn().mockResolvedValueOnce(null),
|
||||
create: vi.fn().mockResolvedValueOnce({}),
|
||||
},
|
||||
workspace: {
|
||||
create: vi.fn().mockResolvedValueOnce(mockWorkspace),
|
||||
},
|
||||
};
|
||||
return fn(txMock as unknown as typeof mockPrismaService);
|
||||
}
|
||||
);
|
||||
|
||||
const result = await service.getUserWorkspaces(mockUserId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe("Test Workspace");
|
||||
expect(result[0].role).toBe(WorkspaceMemberRole.OWNER);
|
||||
expect(mockPrismaService.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should return existing workspace if one was created between initial check and transaction", async () => {
|
||||
// Simulates a race condition: initial findMany returns [], but inside the
|
||||
// transaction another request already created a workspace.
|
||||
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]);
|
||||
mockPrismaService.$transaction.mockImplementationOnce(
|
||||
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) => {
|
||||
const txMock = {
|
||||
workspaceMember: {
|
||||
findFirst: vi.fn().mockResolvedValueOnce(mockMembership),
|
||||
},
|
||||
workspace: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
return fn(txMock as unknown as typeof mockPrismaService);
|
||||
}
|
||||
);
|
||||
|
||||
const result = await service.getUserWorkspaces(mockUserId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mockWorkspaceId);
|
||||
expect(result[0].name).toBe("Test Workspace");
|
||||
});
|
||||
|
||||
it("should create workspace with correct data during auto-provisioning", async () => {
|
||||
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
let capturedWorkspaceData: unknown;
|
||||
let capturedMemberData: unknown;
|
||||
|
||||
mockPrismaService.$transaction.mockImplementationOnce(
|
||||
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) => {
|
||||
const txMock = {
|
||||
workspaceMember: {
|
||||
findFirst: vi.fn().mockResolvedValueOnce(null),
|
||||
create: vi.fn().mockImplementation((args: unknown) => {
|
||||
capturedMemberData = args;
|
||||
return {};
|
||||
}),
|
||||
},
|
||||
workspace: {
|
||||
create: vi.fn().mockImplementation((args: unknown) => {
|
||||
capturedWorkspaceData = args;
|
||||
return mockWorkspace;
|
||||
}),
|
||||
},
|
||||
};
|
||||
return fn(txMock as unknown as typeof mockPrismaService);
|
||||
}
|
||||
);
|
||||
|
||||
await service.getUserWorkspaces(mockUserId);
|
||||
|
||||
expect(capturedWorkspaceData).toEqual({
|
||||
data: {
|
||||
name: "My Workspace",
|
||||
ownerId: mockUserId,
|
||||
settings: {},
|
||||
},
|
||||
});
|
||||
expect(capturedMemberData).toEqual({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should not auto-provision when user already has workspaces", async () => {
|
||||
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([mockMembership]);
|
||||
|
||||
await service.getUserWorkspaces(mockUserId);
|
||||
|
||||
expect(mockPrismaService.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should propagate database errors", async () => {
|
||||
mockPrismaService.workspaceMember.findMany.mockRejectedValueOnce(
|
||||
new Error("Database connection failed")
|
||||
);
|
||||
|
||||
await expect(service.getUserWorkspaces(mockUserId)).rejects.toThrow(
|
||||
"Database connection failed"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
97
apps/api/src/workspaces/workspaces.service.ts
Normal file
97
apps/api/src/workspaces/workspaces.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user