diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 79810b8..994d191 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -42,6 +42,7 @@ import { SpeechModule } from "./speech/speech.module"; import { DashboardModule } from "./dashboard/dashboard.module"; import { TerminalModule } from "./terminal/terminal.module"; import { PersonalitiesModule } from "./personalities/personalities.module"; +import { WorkspacesModule } from "./workspaces/workspaces.module"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; @Module({ @@ -107,6 +108,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce DashboardModule, TerminalModule, PersonalitiesModule, + WorkspacesModule, ], controllers: [AppController, CsrfController], providers: [ diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts index 8f54a32..3740338 100644 --- a/apps/api/src/auth/auth.controller.spec.ts +++ b/apps/api/src/auth/auth.controller.spec.ts @@ -361,16 +361,13 @@ describe("AuthController", () => { }); describe("getProfile", () => { - it("should return complete user profile with workspace fields", () => { + it("should return complete user profile with identity fields", () => { const mockUser: AuthUser = { id: "user-123", email: "test@example.com", name: "Test User", image: "https://example.com/avatar.jpg", emailVerified: true, - workspaceId: "workspace-123", - currentWorkspaceId: "workspace-456", - workspaceRole: "admin", }; const result = controller.getProfile(mockUser); @@ -381,13 +378,10 @@ describe("AuthController", () => { name: mockUser.name, image: mockUser.image, emailVerified: mockUser.emailVerified, - workspaceId: mockUser.workspaceId, - currentWorkspaceId: mockUser.currentWorkspaceId, - workspaceRole: mockUser.workspaceRole, }); }); - it("should return user profile with optional fields undefined", () => { + it("should return user profile with only required fields", () => { const mockUser: AuthUser = { id: "user-123", email: "test@example.com", @@ -400,12 +394,11 @@ describe("AuthController", () => { id: mockUser.id, email: mockUser.email, name: mockUser.name, - image: undefined, - emailVerified: undefined, - workspaceId: undefined, - currentWorkspaceId: undefined, - workspaceRole: undefined, }); + // Workspace fields are not included — served by GET /api/workspaces + expect(result).not.toHaveProperty("workspaceId"); + expect(result).not.toHaveProperty("currentWorkspaceId"); + expect(result).not.toHaveProperty("workspaceRole"); }); }); diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts index b8802f7..f0bd96b 100644 --- a/apps/api/src/auth/auth.controller.ts +++ b/apps/api/src/auth/auth.controller.ts @@ -72,15 +72,10 @@ export class AuthController { if (user.emailVerified !== undefined) { profile.emailVerified = user.emailVerified; } - if (user.workspaceId !== undefined) { - profile.workspaceId = user.workspaceId; - } - if (user.currentWorkspaceId !== undefined) { - profile.currentWorkspaceId = user.currentWorkspaceId; - } - if (user.workspaceRole !== undefined) { - profile.workspaceRole = user.workspaceRole; - } + + // Workspace context is served by GET /api/workspaces, not the auth profile. + // The deprecated workspaceId/currentWorkspaceId/workspaceRole fields on + // AuthUser are never populated by BetterAuth and are omitted here. return profile; } diff --git a/apps/api/src/workspaces/dto/index.ts b/apps/api/src/workspaces/dto/index.ts new file mode 100644 index 0000000..32bb49a --- /dev/null +++ b/apps/api/src/workspaces/dto/index.ts @@ -0,0 +1 @@ +export { WorkspaceResponseDto } from "./workspace-response.dto"; diff --git a/apps/api/src/workspaces/dto/workspace-response.dto.ts b/apps/api/src/workspaces/dto/workspace-response.dto.ts new file mode 100644 index 0000000..b89d294 --- /dev/null +++ b/apps/api/src/workspaces/dto/workspace-response.dto.ts @@ -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; +} diff --git a/apps/api/src/workspaces/index.ts b/apps/api/src/workspaces/index.ts new file mode 100644 index 0000000..402915b --- /dev/null +++ b/apps/api/src/workspaces/index.ts @@ -0,0 +1,3 @@ +export { WorkspacesModule } from "./workspaces.module"; +export { WorkspacesService } from "./workspaces.service"; +export { WorkspacesController } from "./workspaces.controller"; diff --git a/apps/api/src/workspaces/workspaces.controller.spec.ts b/apps/api/src/workspaces/workspaces.controller.spec.ts new file mode 100644 index 0000000..31a04ee --- /dev/null +++ b/apps/api/src/workspaces/workspaces.controller.spec.ts @@ -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); + service = module.get(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"); + }); + }); +}); diff --git a/apps/api/src/workspaces/workspaces.controller.ts b/apps/api/src/workspaces/workspaces.controller.ts new file mode 100644 index 0000000..d23f825 --- /dev/null +++ b/apps/api/src/workspaces/workspaces.controller.ts @@ -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 { + return this.workspacesService.getUserWorkspaces(user.id); + } +} diff --git a/apps/api/src/workspaces/workspaces.module.ts b/apps/api/src/workspaces/workspaces.module.ts new file mode 100644 index 0000000..e9157fa --- /dev/null +++ b/apps/api/src/workspaces/workspaces.module.ts @@ -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 {} diff --git a/apps/api/src/workspaces/workspaces.service.spec.ts b/apps/api/src/workspaces/workspaces.service.spec.ts new file mode 100644 index 0000000..2c87b0c --- /dev/null +++ b/apps/api/src/workspaces/workspaces.service.spec.ts @@ -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); + + 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) => { + 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) => { + 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) => { + 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" + ); + }); + }); +}); diff --git a/apps/api/src/workspaces/workspaces.service.ts b/apps/api/src/workspaces/workspaces.service.ts new file mode 100644 index 0000000..247932e --- /dev/null +++ b/apps/api/src/workspaces/workspaces.service.ts @@ -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 { + 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, + }, + ]; + } +} diff --git a/apps/web/src/app/(authenticated)/profile/page.tsx b/apps/web/src/app/(authenticated)/profile/page.tsx index f60d696..9ae73fc 100644 --- a/apps/web/src/app/(authenticated)/profile/page.tsx +++ b/apps/web/src/app/(authenticated)/profile/page.tsx @@ -265,23 +265,8 @@ export default function ProfilePage(): ReactElement {

)} - {user?.workspaceRole && ( - - {user.workspaceRole} - - )} + {/* Workspace role badge — placeholder until workspace context API + provides role data via GET /api/workspaces */} diff --git a/apps/web/src/components/chat/Chat.tsx b/apps/web/src/components/chat/Chat.tsx index 7934614..a06a538 100644 --- a/apps/web/src/components/chat/Chat.tsx +++ b/apps/web/src/components/chat/Chat.tsx @@ -5,6 +5,7 @@ import { useAuth } from "@/lib/auth/auth-context"; import { useChat } from "@/hooks/useChat"; import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands"; import { useWebSocket } from "@/hooks/useWebSocket"; +import { useWorkspaceId } from "@/lib/hooks"; import { MessageList } from "./MessageList"; import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput"; import { ChatEmptyState } from "./ChatEmptyState"; @@ -89,10 +90,10 @@ export const Chat = forwardRef(function Chat( ...(initialProjectId !== undefined && { projectId: initialProjectId }), }); - // Use the actual workspace ID for the WebSocket room subscription. + // Read workspace ID from localStorage (set by auth-context after session check). // Cookie-based auth (withCredentials) handles authentication, so no explicit // token is needed here — pass an empty string as the token placeholder. - const workspaceId = user?.currentWorkspaceId ?? user?.workspaceId ?? ""; + const workspaceId = useWorkspaceId() ?? ""; const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {}); const { isCommand, executeCommand } = useOrchestratorCommands(); diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx index 8be56ff..b678795 100644 --- a/apps/web/src/components/layout/AppSidebar.tsx +++ b/apps/web/src/components/layout/AppSidebar.tsx @@ -464,7 +464,7 @@ function UserCard({ collapsed }: UserCardProps): React.JSX.Element { const displayName = user?.name ?? "User"; const initials = getInitials(displayName); - const role = user?.workspaceRole ?? "Member"; + const role = "Member"; return (