Compare commits
35 Commits
5ed0a859da
...
feat/ms21-
| Author | SHA1 | Date | |
|---|---|---|---|
| de6aa9c768 | |||
| 85d3f930f3 | |||
| 0e6734bdae | |||
| 5bcaaeddd9 | |||
| 676a2a288b | |||
| ac16d6ed88 | |||
| 8388d49786 | |||
| 20f914ea85 | |||
| 1b84741f1a | |||
| ffc10c9a45 | |||
| 62d9ac0e5a | |||
| 8098504fb8 | |||
| 128431ba58 | |||
| d2c51eda91 | |||
| 78b643a945 | |||
| f93503ebcf | |||
| c0e679ab7c | |||
| 6ac63fe755 | |||
| 1667f28d71 | |||
| 66fe475fa1 | |||
| d39ab6aafc | |||
| 147e8ac574 | |||
| c38bfae16c | |||
| 36b4d8323d | |||
| 833662a64f | |||
| b3922e1d5b | |||
| 78b71a0ecc | |||
| dd0568cf15 | |||
| 8964226163 | |||
| 11f22a7e96 | |||
| edcff6a0e0 | |||
| e3cba37e8c | |||
| 21bf7e050f | |||
| 83d5aee53a | |||
| cc5b108b2f |
30
.env.example
30
.env.example
@@ -79,7 +79,7 @@ OIDC_CLIENT_ID=your-client-id-here
|
||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||
# Redirect URI must match what's configured in Authentik
|
||||
# Development: http://localhost:3001/auth/oauth2/callback/authentik
|
||||
# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
|
||||
# Production: https://mosaic-api.woltje.com/auth/oauth2/callback/authentik
|
||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||
|
||||
# Authentik PostgreSQL Database
|
||||
@@ -314,17 +314,19 @@ COORDINATOR_ENABLED=true
|
||||
# TTL is in seconds, limits are per TTL window
|
||||
|
||||
# Global rate limit (applies to all endpoints unless overridden)
|
||||
RATE_LIMIT_TTL=60 # Time window in seconds
|
||||
RATE_LIMIT_GLOBAL_LIMIT=100 # Requests per window
|
||||
# Time window in seconds
|
||||
RATE_LIMIT_TTL=60
|
||||
# Requests per window
|
||||
RATE_LIMIT_GLOBAL_LIMIT=100
|
||||
|
||||
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch)
|
||||
RATE_LIMIT_WEBHOOK_LIMIT=60 # Requests per minute
|
||||
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute
|
||||
RATE_LIMIT_WEBHOOK_LIMIT=60
|
||||
|
||||
# Coordinator endpoints (/coordinator/*)
|
||||
RATE_LIMIT_COORDINATOR_LIMIT=100 # Requests per minute
|
||||
# Coordinator endpoints (/coordinator/*) — requests per minute
|
||||
RATE_LIMIT_COORDINATOR_LIMIT=100
|
||||
|
||||
# Health check endpoints (/coordinator/health)
|
||||
RATE_LIMIT_HEALTH_LIMIT=300 # Requests per minute (higher for monitoring)
|
||||
# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring)
|
||||
RATE_LIMIT_HEALTH_LIMIT=300
|
||||
|
||||
# Storage backend for rate limiting (redis or memory)
|
||||
# redis: Uses Valkey for distributed rate limiting (recommended for production)
|
||||
@@ -359,17 +361,17 @@ RATE_LIMIT_STORAGE=redis
|
||||
# a single workspace.
|
||||
MATRIX_HOMESERVER_URL=http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN=
|
||||
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.example.com
|
||||
MATRIX_SERVER_NAME=matrix.example.com
|
||||
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.example.com
|
||||
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
|
||||
MATRIX_SERVER_NAME=matrix.woltje.com
|
||||
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
|
||||
# MATRIX_WORKSPACE_ID=your-workspace-uuid
|
||||
|
||||
# ======================
|
||||
# Matrix / Synapse Deployment
|
||||
# ======================
|
||||
# Domains for Traefik routing to Matrix services
|
||||
MATRIX_DOMAIN=matrix.example.com
|
||||
ELEMENT_DOMAIN=chat.example.com
|
||||
MATRIX_DOMAIN=matrix.woltje.com
|
||||
ELEMENT_DOMAIN=chat.woltje.com
|
||||
|
||||
# Synapse database (created automatically by synapse-db-init in the swarm compose)
|
||||
SYNAPSE_POSTGRES_DB=synapse
|
||||
|
||||
@@ -1,14 +1,80 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mission_id": "prd-implementation-20260222",
|
||||
"name": "PRD implementation",
|
||||
"description": "",
|
||||
"mission_id": "ms21-multi-tenant-rbac-data-migration-20260228",
|
||||
"name": "MS21 Multi-Tenant RBAC Data Migration",
|
||||
"description": "Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack",
|
||||
"project_path": "/home/jwoltje/src/mosaic-stack",
|
||||
"created_at": "2026-02-23T03:20:55Z",
|
||||
"created_at": "2026-02-28T17:10:22Z",
|
||||
"status": "active",
|
||||
"task_prefix": "",
|
||||
"quality_gates": "",
|
||||
"milestone_version": "0.0.20",
|
||||
"milestones": [],
|
||||
"sessions": []
|
||||
"task_prefix": "MS21",
|
||||
"quality_gates": "pnpm lint && pnpm build && pnpm test",
|
||||
"milestone_version": "0.0.21",
|
||||
"milestones": [
|
||||
{
|
||||
"id": "phase-1",
|
||||
"name": "Schema and Admin API",
|
||||
"status": "pending",
|
||||
"branch": "schema-and-admin-api",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-2",
|
||||
"name": "Break-Glass Authentication",
|
||||
"status": "pending",
|
||||
"branch": "break-glass-authentication",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-3",
|
||||
"name": "Data Migration",
|
||||
"status": "pending",
|
||||
"branch": "data-migration",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-4",
|
||||
"name": "Admin UI",
|
||||
"status": "pending",
|
||||
"branch": "admin-ui",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-5",
|
||||
"name": "RBAC UI Enforcement",
|
||||
"status": "pending",
|
||||
"branch": "rbac-ui-enforcement",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-6",
|
||||
"name": "Verification",
|
||||
"status": "pending",
|
||||
"branch": "verification",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": "sess-001",
|
||||
"runtime": "unknown",
|
||||
"started_at": "2026-02-28T17:48:51Z",
|
||||
"ended_at": "",
|
||||
"ended_reason": "",
|
||||
"milestone_at_end": "",
|
||||
"tasks_completed": [],
|
||||
"last_task_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
8
.mosaic/orchestrator/session.lock
Normal file
8
.mosaic/orchestrator/session.lock
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"session_id": "sess-001",
|
||||
"runtime": "unknown",
|
||||
"pid": 2396592,
|
||||
"started_at": "2026-02-28T17:48:51Z",
|
||||
"project_path": "/tmp/ms21-api-003",
|
||||
"milestone_id": ""
|
||||
}
|
||||
@@ -52,6 +52,7 @@
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.13.5",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.4.17",
|
||||
"bullmq": "^5.67.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -85,6 +86,7 @@
|
||||
"@swc/core": "^1.10.18",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/highlight.js": "^10.1.0",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable: add tone and formality_level columns to personalities
|
||||
ALTER TABLE "personalities" ADD COLUMN "tone" TEXT NOT NULL DEFAULT 'neutral';
|
||||
ALTER TABLE "personalities" ADD COLUMN "formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL';
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
previewFeatures = ["postgresqlExtensions"]
|
||||
}
|
||||
|
||||
@@ -226,6 +227,14 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// MS21: Admin, local auth, and invitation fields
|
||||
deactivatedAt DateTime? @map("deactivated_at") @db.Timestamptz
|
||||
isLocalAuth Boolean @default(false) @map("is_local_auth")
|
||||
passwordHash String? @map("password_hash")
|
||||
invitedBy String? @map("invited_by") @db.Uuid
|
||||
invitationToken String? @unique @map("invitation_token")
|
||||
invitedAt DateTime? @map("invited_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
|
||||
workspaceMemberships WorkspaceMember[]
|
||||
@@ -1067,6 +1076,10 @@ model Personality {
|
||||
displayName String @map("display_name")
|
||||
description String? @db.Text
|
||||
|
||||
// Tone and formality
|
||||
tone String @default("neutral")
|
||||
formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
|
||||
|
||||
// System prompt
|
||||
systemPrompt String @map("system_prompt") @db.Text
|
||||
|
||||
|
||||
258
apps/api/src/admin/admin.controller.spec.ts
Normal file
258
apps/api/src/admin/admin.controller.spec.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import type { ExecutionContext } from "@nestjs/common";
|
||||
|
||||
describe("AdminController", () => {
|
||||
let controller: AdminController;
|
||||
let service: AdminService;
|
||||
|
||||
const mockAdminService = {
|
||||
listUsers: vi.fn(),
|
||||
inviteUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deactivateUser: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
updateWorkspace: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAuthGuard = {
|
||||
canActivate: vi.fn((context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
request.user = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
email: "admin@example.com",
|
||||
name: "Admin User",
|
||||
};
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
const mockAdminGuard = {
|
||||
canActivate: vi.fn(() => true),
|
||||
};
|
||||
|
||||
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
const mockAdminUser = {
|
||||
id: mockAdminId,
|
||||
email: "admin@example.com",
|
||||
name: "Admin User",
|
||||
};
|
||||
|
||||
const mockUserResponse = {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: false,
|
||||
image: null,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
invitedAt: null,
|
||||
invitedBy: null,
|
||||
workspaceMemberships: [],
|
||||
};
|
||||
|
||||
const mockWorkspaceResponse = {
|
||||
id: mockWorkspaceId,
|
||||
name: "Test Workspace",
|
||||
ownerId: mockAdminId,
|
||||
settings: {},
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
memberCount: 1,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
{
|
||||
provide: AdminService,
|
||||
useValue: mockAdminService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(mockAuthGuard)
|
||||
.overrideGuard(AdminGuard)
|
||||
.useValue(mockAdminGuard)
|
||||
.compile();
|
||||
|
||||
controller = module.get<AdminController>(AdminController);
|
||||
service = module.get<AdminService>(AdminService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("listUsers", () => {
|
||||
it("should return paginated users", async () => {
|
||||
const paginatedResult = {
|
||||
data: [mockUserResponse],
|
||||
meta: { total: 1, page: 1, limit: 50, totalPages: 1 },
|
||||
};
|
||||
mockAdminService.listUsers.mockResolvedValue(paginatedResult);
|
||||
|
||||
const result = await controller.listUsers({ page: 1, limit: 50 });
|
||||
|
||||
expect(result).toEqual(paginatedResult);
|
||||
expect(service.listUsers).toHaveBeenCalledWith(1, 50);
|
||||
});
|
||||
|
||||
it("should use default pagination", async () => {
|
||||
const paginatedResult = {
|
||||
data: [],
|
||||
meta: { total: 0, page: 1, limit: 50, totalPages: 0 },
|
||||
};
|
||||
mockAdminService.listUsers.mockResolvedValue(paginatedResult);
|
||||
|
||||
await controller.listUsers({});
|
||||
|
||||
expect(service.listUsers).toHaveBeenCalledWith(undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inviteUser", () => {
|
||||
it("should invite a user", async () => {
|
||||
const inviteDto = { email: "new@example.com" };
|
||||
const invitationResponse = {
|
||||
userId: "new-id",
|
||||
invitationToken: "token",
|
||||
email: "new@example.com",
|
||||
invitedAt: new Date(),
|
||||
};
|
||||
mockAdminService.inviteUser.mockResolvedValue(invitationResponse);
|
||||
|
||||
const result = await controller.inviteUser(inviteDto, mockAdminUser);
|
||||
|
||||
expect(result).toEqual(invitationResponse);
|
||||
expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId);
|
||||
});
|
||||
|
||||
it("should invite a user with workspace and role", async () => {
|
||||
const inviteDto = {
|
||||
email: "new@example.com",
|
||||
workspaceId: mockWorkspaceId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
};
|
||||
mockAdminService.inviteUser.mockResolvedValue({
|
||||
userId: "new-id",
|
||||
invitationToken: "token",
|
||||
email: "new@example.com",
|
||||
invitedAt: new Date(),
|
||||
});
|
||||
|
||||
await controller.inviteUser(inviteDto, mockAdminUser);
|
||||
|
||||
expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUser", () => {
|
||||
it("should update a user", async () => {
|
||||
const updateDto = { name: "Updated Name" };
|
||||
mockAdminService.updateUser.mockResolvedValue({
|
||||
...mockUserResponse,
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
const result = await controller.updateUser(mockUserId, updateDto);
|
||||
|
||||
expect(result.name).toBe("Updated Name");
|
||||
expect(service.updateUser).toHaveBeenCalledWith(mockUserId, updateDto);
|
||||
});
|
||||
|
||||
it("should deactivate a user via update", async () => {
|
||||
const deactivatedAt = "2026-02-28T00:00:00.000Z";
|
||||
const updateDto = { deactivatedAt };
|
||||
mockAdminService.updateUser.mockResolvedValue({
|
||||
...mockUserResponse,
|
||||
deactivatedAt: new Date(deactivatedAt),
|
||||
});
|
||||
|
||||
const result = await controller.updateUser(mockUserId, updateDto);
|
||||
|
||||
expect(result.deactivatedAt).toEqual(new Date(deactivatedAt));
|
||||
});
|
||||
});
|
||||
|
||||
describe("deactivateUser", () => {
|
||||
it("should soft-delete a user", async () => {
|
||||
mockAdminService.deactivateUser.mockResolvedValue({
|
||||
...mockUserResponse,
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await controller.deactivateUser(mockUserId);
|
||||
|
||||
expect(result.deactivatedAt).toBeDefined();
|
||||
expect(service.deactivateUser).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorkspace", () => {
|
||||
it("should create a workspace", async () => {
|
||||
const createDto = { name: "New Workspace", ownerId: mockAdminId };
|
||||
mockAdminService.createWorkspace.mockResolvedValue(mockWorkspaceResponse);
|
||||
|
||||
const result = await controller.createWorkspace(createDto);
|
||||
|
||||
expect(result).toEqual(mockWorkspaceResponse);
|
||||
expect(service.createWorkspace).toHaveBeenCalledWith(createDto);
|
||||
});
|
||||
|
||||
it("should create workspace with settings", async () => {
|
||||
const createDto = {
|
||||
name: "New Workspace",
|
||||
ownerId: mockAdminId,
|
||||
settings: { feature: true },
|
||||
};
|
||||
mockAdminService.createWorkspace.mockResolvedValue({
|
||||
...mockWorkspaceResponse,
|
||||
settings: { feature: true },
|
||||
});
|
||||
|
||||
const result = await controller.createWorkspace(createDto);
|
||||
|
||||
expect(result.settings).toEqual({ feature: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWorkspace", () => {
|
||||
it("should update a workspace", async () => {
|
||||
const updateDto = { name: "Updated Workspace" };
|
||||
mockAdminService.updateWorkspace.mockResolvedValue({
|
||||
...mockWorkspaceResponse,
|
||||
name: "Updated Workspace",
|
||||
});
|
||||
|
||||
const result = await controller.updateWorkspace(mockWorkspaceId, updateDto);
|
||||
|
||||
expect(result.name).toBe("Updated Workspace");
|
||||
expect(service.updateWorkspace).toHaveBeenCalledWith(mockWorkspaceId, updateDto);
|
||||
});
|
||||
|
||||
it("should update workspace settings", async () => {
|
||||
const updateDto = { settings: { notifications: false } };
|
||||
mockAdminService.updateWorkspace.mockResolvedValue({
|
||||
...mockWorkspaceResponse,
|
||||
settings: { notifications: false },
|
||||
});
|
||||
|
||||
const result = await controller.updateWorkspace(mockWorkspaceId, updateDto);
|
||||
|
||||
expect(result.settings).toEqual({ notifications: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
64
apps/api/src/admin/admin.controller.ts
Normal file
64
apps/api/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from "@nestjs/common";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import type { AuthUser } from "@mosaic/shared";
|
||||
import { InviteUserDto } from "./dto/invite-user.dto";
|
||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||
import { CreateWorkspaceDto } from "./dto/create-workspace.dto";
|
||||
import { UpdateWorkspaceDto } from "./dto/update-workspace.dto";
|
||||
import { QueryUsersDto } from "./dto/query-users.dto";
|
||||
|
||||
@Controller("admin")
|
||||
@UseGuards(AuthGuard, AdminGuard)
|
||||
export class AdminController {
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@Get("users")
|
||||
async listUsers(@Query() query: QueryUsersDto) {
|
||||
return this.adminService.listUsers(query.page, query.limit);
|
||||
}
|
||||
|
||||
@Post("users/invite")
|
||||
async inviteUser(@Body() dto: InviteUserDto, @CurrentUser() user: AuthUser) {
|
||||
return this.adminService.inviteUser(dto, user.id);
|
||||
}
|
||||
|
||||
@Patch("users/:id")
|
||||
async updateUser(
|
||||
@Param("id", new ParseUUIDPipe({ version: "4" })) id: string,
|
||||
@Body() dto: UpdateUserDto
|
||||
) {
|
||||
return this.adminService.updateUser(id, dto);
|
||||
}
|
||||
|
||||
@Delete("users/:id")
|
||||
async deactivateUser(@Param("id", new ParseUUIDPipe({ version: "4" })) id: string) {
|
||||
return this.adminService.deactivateUser(id);
|
||||
}
|
||||
|
||||
@Post("workspaces")
|
||||
async createWorkspace(@Body() dto: CreateWorkspaceDto) {
|
||||
return this.adminService.createWorkspace(dto);
|
||||
}
|
||||
|
||||
@Patch("workspaces/:id")
|
||||
async updateWorkspace(
|
||||
@Param("id", new ParseUUIDPipe({ version: "4" })) id: string,
|
||||
@Body() dto: UpdateWorkspaceDto
|
||||
) {
|
||||
return this.adminService.updateWorkspace(id, dto);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/admin/admin.module.ts
Normal file
13
apps/api/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
exports: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
471
apps/api/src/admin/admin.service.spec.ts
Normal file
471
apps/api/src/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
|
||||
describe("AdminService", () => {
|
||||
let service: AdminService;
|
||||
|
||||
const mockPrismaService = {
|
||||
user: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
workspaceMember: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: false,
|
||||
image: null,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
invitedBy: null,
|
||||
invitationToken: null,
|
||||
invitedAt: null,
|
||||
authProviderId: null,
|
||||
preferences: {},
|
||||
workspaceMemberships: [
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
workspace: { id: mockWorkspaceId, name: "Test Workspace" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockWorkspace = {
|
||||
id: mockWorkspaceId,
|
||||
name: "Test Workspace",
|
||||
ownerId: mockAdminId,
|
||||
settings: {},
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
matrixRoomId: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AdminService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AdminService>(AdminService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockPrismaService.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => {
|
||||
return fn(mockPrismaService);
|
||||
});
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("listUsers", () => {
|
||||
it("should return paginated users with memberships", async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([mockUser]);
|
||||
mockPrismaService.user.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.listUsers(1, 50);
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0]?.id).toBe(mockUserId);
|
||||
expect(result.data[0]?.workspaceMemberships).toHaveLength(1);
|
||||
expect(result.meta).toEqual({
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalPages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use default pagination when not provided", async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.user.count.mockResolvedValue(0);
|
||||
|
||||
await service.listUsers();
|
||||
|
||||
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 0,
|
||||
take: 50,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should calculate pagination correctly", async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.user.count.mockResolvedValue(150);
|
||||
|
||||
const result = await service.listUsers(3, 25);
|
||||
|
||||
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 50,
|
||||
take: 25,
|
||||
})
|
||||
);
|
||||
expect(result.meta.totalPages).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inviteUser", () => {
|
||||
it("should create a user with invitation token", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
const createdUser = {
|
||||
id: "new-user-id",
|
||||
email: "new@example.com",
|
||||
name: "new",
|
||||
invitationToken: "some-token",
|
||||
};
|
||||
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||
|
||||
const result = await service.inviteUser({ email: "new@example.com" }, mockAdminId);
|
||||
|
||||
expect(result.email).toBe("new@example.com");
|
||||
expect(result.invitationToken).toBeDefined();
|
||||
expect(result.userId).toBe("new-user-id");
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
email: "new@example.com",
|
||||
invitedBy: mockAdminId,
|
||||
invitationToken: expect.any(String),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should add user to workspace when workspaceId provided", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||
const createdUser = { id: "new-user-id", email: "new@example.com", name: "new" };
|
||||
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||
|
||||
await service.inviteUser(
|
||||
{
|
||||
email: "new@example.com",
|
||||
workspaceId: mockWorkspaceId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
},
|
||||
mockAdminId
|
||||
);
|
||||
|
||||
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: "new-user-id",
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw ConflictException if email already exists", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(service.inviteUser({ email: "test@example.com" }, mockAdminId)).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if workspace does not exist", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.inviteUser({ email: "new@example.com", workspaceId: "non-existent" }, mockAdminId)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should use email prefix as default name", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
const createdUser = { id: "new-user-id", email: "jane.doe@example.com", name: "jane.doe" };
|
||||
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||
|
||||
await service.inviteUser({ email: "jane.doe@example.com" }, mockAdminId);
|
||||
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: "jane.doe",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should use provided name when given", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
const createdUser = { id: "new-user-id", email: "j@example.com", name: "Jane Doe" };
|
||||
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||
|
||||
await service.inviteUser({ email: "j@example.com", name: "Jane Doe" }, mockAdminId);
|
||||
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: "Jane Doe",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUser", () => {
|
||||
it("should update user fields", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
const result = await service.updateUser(mockUserId, { name: "Updated Name" });
|
||||
|
||||
expect(result.name).toBe("Updated Name");
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: mockUserId },
|
||||
data: { name: "Updated Name" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should set deactivatedAt when provided", async () => {
|
||||
const deactivatedAt = "2026-02-28T00:00:00.000Z";
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
deactivatedAt: new Date(deactivatedAt),
|
||||
});
|
||||
|
||||
const result = await service.updateUser(mockUserId, { deactivatedAt });
|
||||
|
||||
expect(result.deactivatedAt).toEqual(new Date(deactivatedAt));
|
||||
});
|
||||
|
||||
it("should clear deactivatedAt when set to null", async () => {
|
||||
const deactivatedUser = { ...mockUser, deactivatedAt: new Date() };
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(deactivatedUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...deactivatedUser,
|
||||
deactivatedAt: null,
|
||||
});
|
||||
|
||||
const result = await service.updateUser(mockUserId, { deactivatedAt: null });
|
||||
|
||||
expect(result.deactivatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if user does not exist", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateUser("non-existent", { name: "Test" })).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should update emailVerified", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
const result = await service.updateUser(mockUserId, { emailVerified: true });
|
||||
|
||||
expect(result.emailVerified).toBe(true);
|
||||
});
|
||||
|
||||
it("should update preferences", async () => {
|
||||
const prefs = { theme: "dark" };
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
preferences: prefs,
|
||||
});
|
||||
|
||||
await service.updateUser(mockUserId, { preferences: prefs });
|
||||
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ preferences: prefs }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deactivateUser", () => {
|
||||
it("should set deactivatedAt on the user", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.deactivateUser(mockUserId);
|
||||
|
||||
expect(result.deactivatedAt).toBeDefined();
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: mockUserId },
|
||||
data: { deactivatedAt: expect.any(Date) },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if user does not exist", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deactivateUser("non-existent")).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException if user is already deactivated", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
...mockUser,
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(service.deactivateUser(mockUserId)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorkspace", () => {
|
||||
it("should create a workspace with owner membership", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.workspace.create.mockResolvedValue(mockWorkspace);
|
||||
|
||||
const result = await service.createWorkspace({
|
||||
name: "New Workspace",
|
||||
ownerId: mockAdminId,
|
||||
});
|
||||
|
||||
expect(result.name).toBe("Test Workspace");
|
||||
expect(result.memberCount).toBe(1);
|
||||
expect(mockPrismaService.workspace.create).toHaveBeenCalled();
|
||||
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspace.id,
|
||||
userId: mockAdminId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if owner does not exist", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.createWorkspace({ name: "New Workspace", ownerId: "non-existent" })
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should pass settings when provided", async () => {
|
||||
const settings = { theme: "dark", features: ["chat"] };
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.workspace.create.mockResolvedValue({
|
||||
...mockWorkspace,
|
||||
settings,
|
||||
});
|
||||
|
||||
await service.createWorkspace({
|
||||
name: "New Workspace",
|
||||
ownerId: mockAdminId,
|
||||
settings,
|
||||
});
|
||||
|
||||
expect(mockPrismaService.workspace.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ settings }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWorkspace", () => {
|
||||
it("should update workspace name", async () => {
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||
mockPrismaService.workspace.update.mockResolvedValue({
|
||||
...mockWorkspace,
|
||||
name: "Updated Workspace",
|
||||
_count: { members: 3 },
|
||||
});
|
||||
|
||||
const result = await service.updateWorkspace(mockWorkspaceId, {
|
||||
name: "Updated Workspace",
|
||||
});
|
||||
|
||||
expect(result.name).toBe("Updated Workspace");
|
||||
expect(result.memberCount).toBe(3);
|
||||
});
|
||||
|
||||
it("should update workspace settings", async () => {
|
||||
const newSettings = { notifications: true };
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||
mockPrismaService.workspace.update.mockResolvedValue({
|
||||
...mockWorkspace,
|
||||
settings: newSettings,
|
||||
_count: { members: 1 },
|
||||
});
|
||||
|
||||
const result = await service.updateWorkspace(mockWorkspaceId, {
|
||||
settings: newSettings,
|
||||
});
|
||||
|
||||
expect(result.settings).toEqual(newSettings);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if workspace does not exist", async () => {
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateWorkspace("non-existent", { name: "Test" })).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should only update provided fields", async () => {
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||
mockPrismaService.workspace.update.mockResolvedValue({
|
||||
...mockWorkspace,
|
||||
_count: { members: 1 },
|
||||
});
|
||||
|
||||
await service.updateWorkspace(mockWorkspaceId, { name: "Only Name" });
|
||||
|
||||
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { name: "Only Name" },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
306
apps/api/src/admin/admin.service.ts
Normal file
306
apps/api/src/admin/admin.service.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Prisma, WorkspaceMemberRole } from "@prisma/client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { InviteUserDto } from "./dto/invite-user.dto";
|
||||
import type { UpdateUserDto } from "./dto/update-user.dto";
|
||||
import type { CreateWorkspaceDto } from "./dto/create-workspace.dto";
|
||||
import type {
|
||||
AdminUserResponse,
|
||||
AdminWorkspaceResponse,
|
||||
InvitationResponse,
|
||||
PaginatedResponse,
|
||||
} from "./types/admin.types";
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private readonly logger = new Logger(AdminService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listUsers(page = 1, limit = 50): Promise<PaginatedResponse<AdminUserResponse>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
include: {
|
||||
workspaceMemberships: {
|
||||
include: {
|
||||
workspace: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.user.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: users.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
image: user.image,
|
||||
createdAt: user.createdAt,
|
||||
deactivatedAt: user.deactivatedAt,
|
||||
isLocalAuth: user.isLocalAuth,
|
||||
invitedAt: user.invitedAt,
|
||||
invitedBy: user.invitedBy,
|
||||
workspaceMemberships: user.workspaceMemberships.map((m) => ({
|
||||
workspaceId: m.workspaceId,
|
||||
workspaceName: m.workspace.name,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
})),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async inviteUser(dto: InviteUserDto, inviterId: string): Promise<InvitationResponse> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`User with email ${dto.email} already exists`);
|
||||
}
|
||||
|
||||
if (dto.workspaceId) {
|
||||
const workspace = await this.prisma.workspace.findUnique({
|
||||
where: { id: dto.workspaceId },
|
||||
});
|
||||
if (!workspace) {
|
||||
throw new NotFoundException(`Workspace ${dto.workspaceId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const invitationToken = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
name: dto.name ?? dto.email.split("@")[0] ?? dto.email,
|
||||
emailVerified: false,
|
||||
invitedBy: inviterId,
|
||||
invitationToken,
|
||||
invitedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.workspaceId) {
|
||||
await tx.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId: dto.workspaceId,
|
||||
userId: created.id,
|
||||
role: dto.role ?? WorkspaceMemberRole.MEMBER,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
this.logger.log(`User invited: ${user.email} by ${inviterId}`);
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
invitationToken,
|
||||
email: user.email,
|
||||
invitedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async updateUser(id: string, dto: UpdateUserDto): Promise<AdminUserResponse> {
|
||||
const existing = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`User ${id} not found`);
|
||||
}
|
||||
|
||||
const data: Prisma.UserUpdateInput = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
data.name = dto.name;
|
||||
}
|
||||
if (dto.emailVerified !== undefined) {
|
||||
data.emailVerified = dto.emailVerified;
|
||||
}
|
||||
if (dto.preferences !== undefined) {
|
||||
data.preferences = dto.preferences as Prisma.InputJsonValue;
|
||||
}
|
||||
if (dto.deactivatedAt !== undefined) {
|
||||
data.deactivatedAt = dto.deactivatedAt ? new Date(dto.deactivatedAt) : null;
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
workspaceMemberships: {
|
||||
include: {
|
||||
workspace: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`User updated: ${id}`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
image: user.image,
|
||||
createdAt: user.createdAt,
|
||||
deactivatedAt: user.deactivatedAt,
|
||||
isLocalAuth: user.isLocalAuth,
|
||||
invitedAt: user.invitedAt,
|
||||
invitedBy: user.invitedBy,
|
||||
workspaceMemberships: user.workspaceMemberships.map((m) => ({
|
||||
workspaceId: m.workspaceId,
|
||||
workspaceName: m.workspace.name,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async deactivateUser(id: string): Promise<AdminUserResponse> {
|
||||
const existing = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`User ${id} not found`);
|
||||
}
|
||||
|
||||
if (existing.deactivatedAt) {
|
||||
throw new BadRequestException(`User ${id} is already deactivated`);
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { deactivatedAt: new Date() },
|
||||
include: {
|
||||
workspaceMemberships: {
|
||||
include: {
|
||||
workspace: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`User deactivated: ${id}`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
image: user.image,
|
||||
createdAt: user.createdAt,
|
||||
deactivatedAt: user.deactivatedAt,
|
||||
isLocalAuth: user.isLocalAuth,
|
||||
invitedAt: user.invitedAt,
|
||||
invitedBy: user.invitedBy,
|
||||
workspaceMemberships: user.workspaceMemberships.map((m) => ({
|
||||
workspaceId: m.workspaceId,
|
||||
workspaceName: m.workspace.name,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async createWorkspace(dto: CreateWorkspaceDto): Promise<AdminWorkspaceResponse> {
|
||||
const owner = await this.prisma.user.findUnique({ where: { id: dto.ownerId } });
|
||||
if (!owner) {
|
||||
throw new NotFoundException(`User ${dto.ownerId} not found`);
|
||||
}
|
||||
|
||||
const workspace = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.workspace.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
ownerId: dto.ownerId,
|
||||
settings: dto.settings ? (dto.settings as Prisma.InputJsonValue) : {},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId: created.id,
|
||||
userId: dto.ownerId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
this.logger.log(`Workspace created: ${workspace.id} with owner ${dto.ownerId}`);
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
settings: workspace.settings as Record<string, unknown>,
|
||||
createdAt: workspace.createdAt,
|
||||
updatedAt: workspace.updatedAt,
|
||||
memberCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async updateWorkspace(
|
||||
id: string,
|
||||
dto: { name?: string; settings?: Record<string, unknown> }
|
||||
): Promise<AdminWorkspaceResponse> {
|
||||
const existing = await this.prisma.workspace.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Workspace ${id} not found`);
|
||||
}
|
||||
|
||||
const data: Prisma.WorkspaceUpdateInput = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
data.name = dto.name;
|
||||
}
|
||||
if (dto.settings !== undefined) {
|
||||
data.settings = dto.settings as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
const workspace = await this.prisma.workspace.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
_count: { select: { members: true } },
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Workspace updated: ${id}`);
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
settings: workspace.settings as Record<string, unknown>,
|
||||
createdAt: workspace.createdAt,
|
||||
updatedAt: workspace.updatedAt,
|
||||
memberCount: workspace._count.members,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
apps/api/src/admin/dto/create-workspace.dto.ts
Normal file
15
apps/api/src/admin/dto/create-workspace.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IsObject, IsOptional, IsString, IsUUID, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class CreateWorkspaceDto {
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsUUID("4", { message: "ownerId must be a valid UUID" })
|
||||
ownerId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: "settings must be an object" })
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
20
apps/api/src/admin/dto/invite-user.dto.ts
Normal file
20
apps/api/src/admin/dto/invite-user.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import { IsEmail, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
|
||||
|
||||
export class InviteUserDto {
|
||||
@IsEmail({}, { message: "email must be a valid email address" })
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
|
||||
workspaceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||
role?: WorkspaceMemberRole;
|
||||
}
|
||||
15
apps/api/src/admin/dto/manage-member.dto.ts
Normal file
15
apps/api/src/admin/dto/manage-member.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import { IsEnum, IsUUID } from "class-validator";
|
||||
|
||||
export class AddMemberDto {
|
||||
@IsUUID("4", { message: "userId must be a valid UUID" })
|
||||
userId!: string;
|
||||
|
||||
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||
role!: WorkspaceMemberRole;
|
||||
}
|
||||
|
||||
export class UpdateMemberRoleDto {
|
||||
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||
role!: WorkspaceMemberRole;
|
||||
}
|
||||
17
apps/api/src/admin/dto/query-users.dto.ts
Normal file
17
apps/api/src/admin/dto/query-users.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IsInt, IsOptional, Max, Min } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
export class QueryUsersDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "page must be an integer" })
|
||||
@Min(1, { message: "page must be at least 1" })
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "limit must be an integer" })
|
||||
@Min(1, { message: "limit must be at least 1" })
|
||||
@Max(100, { message: "limit must not exceed 100" })
|
||||
limit?: number;
|
||||
}
|
||||
27
apps/api/src/admin/dto/update-user.dto.ts
Normal file
27
apps/api/src/admin/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: "deactivatedAt must be a valid ISO 8601 date string" })
|
||||
deactivatedAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: "emailVerified must be a boolean" })
|
||||
emailVerified?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: "preferences must be an object" })
|
||||
preferences?: Record<string, unknown>;
|
||||
}
|
||||
13
apps/api/src/admin/dto/update-workspace.dto.ts
Normal file
13
apps/api/src/admin/dto/update-workspace.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsObject, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateWorkspaceDto {
|
||||
@IsOptional()
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: "settings must be an object" })
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
49
apps/api/src/admin/types/admin.types.ts
Normal file
49
apps/api/src/admin/types/admin.types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { WorkspaceMemberRole } from "@prisma/client";
|
||||
|
||||
export interface AdminUserResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
image: string | null;
|
||||
createdAt: Date;
|
||||
deactivatedAt: Date | null;
|
||||
isLocalAuth: boolean;
|
||||
invitedAt: Date | null;
|
||||
invitedBy: string | null;
|
||||
workspaceMemberships: WorkspaceMembershipResponse[];
|
||||
}
|
||||
|
||||
export interface WorkspaceMembershipResponse {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
role: WorkspaceMemberRole;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InvitationResponse {
|
||||
userId: string;
|
||||
invitationToken: string;
|
||||
email: string;
|
||||
invitedAt: Date;
|
||||
}
|
||||
|
||||
export interface AdminWorkspaceResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
settings: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
memberCount: number;
|
||||
}
|
||||
@@ -41,6 +41,10 @@ import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||
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 { AdminModule } from "./admin/admin.module";
|
||||
import { TeamsModule } from "./teams/teams.module";
|
||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||
|
||||
@Module({
|
||||
@@ -105,6 +109,10 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
||||
SpeechModule,
|
||||
DashboardModule,
|
||||
TerminalModule,
|
||||
PersonalitiesModule,
|
||||
WorkspacesModule,
|
||||
AdminModule,
|
||||
TeamsModule,
|
||||
],
|
||||
controllers: [AppController, CsrfController],
|
||||
providers: [
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -3,11 +3,14 @@ import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthGuard } from "./guards/auth.guard";
|
||||
import { LocalAuthController } from "./local/local-auth.controller";
|
||||
import { LocalAuthService } from "./local/local-auth.service";
|
||||
import { LocalAuthEnabledGuard } from "./local/local-auth.guard";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthGuard],
|
||||
controllers: [AuthController, LocalAuthController],
|
||||
providers: [AuthService, AuthGuard, LocalAuthService, LocalAuthEnabledGuard],
|
||||
exports: [AuthService, AuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
10
apps/api/src/auth/local/dto/local-login.dto.ts
Normal file
10
apps/api/src/auth/local/dto/local-login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class LocalLoginDto {
|
||||
@IsEmail({}, { message: "email must be a valid email address" })
|
||||
email!: string;
|
||||
|
||||
@IsString({ message: "password must be a string" })
|
||||
@MinLength(1, { message: "password must not be empty" })
|
||||
password!: string;
|
||||
}
|
||||
20
apps/api/src/auth/local/dto/local-setup.dto.ts
Normal file
20
apps/api/src/auth/local/dto/local-setup.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsEmail, IsString, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
export class LocalSetupDto {
|
||||
@IsEmail({}, { message: "email must be a valid email address" })
|
||||
email!: string;
|
||||
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsString({ message: "password must be a string" })
|
||||
@MinLength(12, { message: "password must be at least 12 characters" })
|
||||
@MaxLength(128, { message: "password must not exceed 128 characters" })
|
||||
password!: string;
|
||||
|
||||
@IsString({ message: "setupToken must be a string" })
|
||||
@MinLength(1, { message: "setupToken must not be empty" })
|
||||
setupToken!: string;
|
||||
}
|
||||
232
apps/api/src/auth/local/local-auth.controller.spec.ts
Normal file
232
apps/api/src/auth/local/local-auth.controller.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import {
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from "@nestjs/common";
|
||||
import { LocalAuthController } from "./local-auth.controller";
|
||||
import { LocalAuthService } from "./local-auth.service";
|
||||
import { LocalAuthEnabledGuard } from "./local-auth.guard";
|
||||
|
||||
describe("LocalAuthController", () => {
|
||||
let controller: LocalAuthController;
|
||||
let localAuthService: LocalAuthService;
|
||||
|
||||
const mockLocalAuthService = {
|
||||
setup: vi.fn(),
|
||||
login: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
headers: { "user-agent": "TestAgent/1.0" },
|
||||
ip: "127.0.0.1",
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
};
|
||||
|
||||
const originalEnv = {
|
||||
ENABLE_LOCAL_AUTH: process.env.ENABLE_LOCAL_AUTH,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.ENABLE_LOCAL_AUTH = "true";
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [LocalAuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: LocalAuthService,
|
||||
useValue: mockLocalAuthService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(LocalAuthEnabledGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<LocalAuthController>(LocalAuthController);
|
||||
localAuthService = module.get<LocalAuthService>(LocalAuthService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalEnv.ENABLE_LOCAL_AUTH !== undefined) {
|
||||
process.env.ENABLE_LOCAL_AUTH = originalEnv.ENABLE_LOCAL_AUTH;
|
||||
} else {
|
||||
delete process.env.ENABLE_LOCAL_AUTH;
|
||||
}
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
const setupDto = {
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
password: "securePassword123!",
|
||||
setupToken: "valid-token-123",
|
||||
};
|
||||
|
||||
const mockSetupResult = {
|
||||
user: {
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
isLocalAuth: true,
|
||||
createdAt: new Date("2026-02-28T00:00:00Z"),
|
||||
},
|
||||
session: {
|
||||
token: "session-token-abc",
|
||||
expiresAt: new Date("2026-03-07T00:00:00Z"),
|
||||
},
|
||||
};
|
||||
|
||||
it("should create a break-glass user and return user data with session", async () => {
|
||||
mockLocalAuthService.setup.mockResolvedValue(mockSetupResult);
|
||||
|
||||
const result = await controller.setup(setupDto, mockRequest as never);
|
||||
|
||||
expect(result).toEqual({
|
||||
user: mockSetupResult.user,
|
||||
session: mockSetupResult.session,
|
||||
});
|
||||
expect(mockLocalAuthService.setup).toHaveBeenCalledWith(
|
||||
"admin@example.com",
|
||||
"Break Glass Admin",
|
||||
"securePassword123!",
|
||||
"valid-token-123",
|
||||
"127.0.0.1",
|
||||
"TestAgent/1.0"
|
||||
);
|
||||
});
|
||||
|
||||
it("should extract client IP from x-forwarded-for header", async () => {
|
||||
mockLocalAuthService.setup.mockResolvedValue(mockSetupResult);
|
||||
const reqWithProxy = {
|
||||
...mockRequest,
|
||||
headers: {
|
||||
...mockRequest.headers,
|
||||
"x-forwarded-for": "203.0.113.50, 70.41.3.18",
|
||||
},
|
||||
};
|
||||
|
||||
await controller.setup(setupDto, reqWithProxy as never);
|
||||
|
||||
expect(mockLocalAuthService.setup).toHaveBeenCalledWith(
|
||||
expect.any(String) as string,
|
||||
expect.any(String) as string,
|
||||
expect.any(String) as string,
|
||||
expect.any(String) as string,
|
||||
"203.0.113.50",
|
||||
"TestAgent/1.0"
|
||||
);
|
||||
});
|
||||
|
||||
it("should propagate ForbiddenException from service", async () => {
|
||||
mockLocalAuthService.setup.mockRejectedValue(new ForbiddenException("Invalid setup token"));
|
||||
|
||||
await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should propagate ConflictException from service", async () => {
|
||||
mockLocalAuthService.setup.mockRejectedValue(
|
||||
new ConflictException("A user with this email already exists")
|
||||
);
|
||||
|
||||
await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
const loginDto = {
|
||||
email: "admin@example.com",
|
||||
password: "securePassword123!",
|
||||
};
|
||||
|
||||
const mockLoginResult = {
|
||||
user: {
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
},
|
||||
session: {
|
||||
token: "session-token-abc",
|
||||
expiresAt: new Date("2026-03-07T00:00:00Z"),
|
||||
},
|
||||
};
|
||||
|
||||
it("should authenticate and return user data with session", async () => {
|
||||
mockLocalAuthService.login.mockResolvedValue(mockLoginResult);
|
||||
|
||||
const result = await controller.login(loginDto, mockRequest as never);
|
||||
|
||||
expect(result).toEqual({
|
||||
user: mockLoginResult.user,
|
||||
session: mockLoginResult.session,
|
||||
});
|
||||
expect(mockLocalAuthService.login).toHaveBeenCalledWith(
|
||||
"admin@example.com",
|
||||
"securePassword123!",
|
||||
"127.0.0.1",
|
||||
"TestAgent/1.0"
|
||||
);
|
||||
});
|
||||
|
||||
it("should propagate UnauthorizedException from service", async () => {
|
||||
mockLocalAuthService.login.mockRejectedValue(
|
||||
new UnauthorizedException("Invalid email or password")
|
||||
);
|
||||
|
||||
await expect(controller.login(loginDto, mockRequest as never)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalAuthEnabledGuard", () => {
|
||||
let guard: LocalAuthEnabledGuard;
|
||||
|
||||
const originalEnv = process.env.ENABLE_LOCAL_AUTH;
|
||||
|
||||
beforeEach(() => {
|
||||
guard = new LocalAuthEnabledGuard();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.ENABLE_LOCAL_AUTH = originalEnv;
|
||||
} else {
|
||||
delete process.env.ENABLE_LOCAL_AUTH;
|
||||
}
|
||||
});
|
||||
|
||||
it("should allow access when ENABLE_LOCAL_AUTH is true", () => {
|
||||
process.env.ENABLE_LOCAL_AUTH = "true";
|
||||
|
||||
expect(guard.canActivate()).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is not set", () => {
|
||||
delete process.env.ENABLE_LOCAL_AUTH;
|
||||
|
||||
expect(() => guard.canActivate()).toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is false", () => {
|
||||
process.env.ENABLE_LOCAL_AUTH = "false";
|
||||
|
||||
expect(() => guard.canActivate()).toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is empty", () => {
|
||||
process.env.ENABLE_LOCAL_AUTH = "";
|
||||
|
||||
expect(() => guard.canActivate()).toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
81
apps/api/src/auth/local/local-auth.controller.ts
Normal file
81
apps/api/src/auth/local/local-auth.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
Logger,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import type { Request as ExpressRequest } from "express";
|
||||
import { SkipCsrf } from "../../common/decorators/skip-csrf.decorator";
|
||||
import { LocalAuthService } from "./local-auth.service";
|
||||
import { LocalAuthEnabledGuard } from "./local-auth.guard";
|
||||
import { LocalLoginDto } from "./dto/local-login.dto";
|
||||
import { LocalSetupDto } from "./dto/local-setup.dto";
|
||||
|
||||
@Controller("auth/local")
|
||||
@UseGuards(LocalAuthEnabledGuard)
|
||||
export class LocalAuthController {
|
||||
private readonly logger = new Logger(LocalAuthController.name);
|
||||
|
||||
constructor(private readonly localAuthService: LocalAuthService) {}
|
||||
|
||||
/**
|
||||
* First-time break-glass user creation.
|
||||
* Requires BREAKGLASS_SETUP_TOKEN from environment.
|
||||
*/
|
||||
@Post("setup")
|
||||
@SkipCsrf()
|
||||
@Throttle({ strict: { limit: 5, ttl: 60000 } })
|
||||
async setup(@Body() dto: LocalSetupDto, @Req() req: ExpressRequest) {
|
||||
const ipAddress = this.getClientIp(req);
|
||||
const userAgent = req.headers["user-agent"];
|
||||
|
||||
this.logger.log(`Break-glass setup attempt from ${ipAddress}`);
|
||||
|
||||
const result = await this.localAuthService.setup(
|
||||
dto.email,
|
||||
dto.name,
|
||||
dto.password,
|
||||
dto.setupToken,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
return {
|
||||
user: result.user,
|
||||
session: result.session,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Break-glass login with email + password.
|
||||
*/
|
||||
@Post("login")
|
||||
@SkipCsrf()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ strict: { limit: 10, ttl: 60000 } })
|
||||
async login(@Body() dto: LocalLoginDto, @Req() req: ExpressRequest) {
|
||||
const ipAddress = this.getClientIp(req);
|
||||
const userAgent = req.headers["user-agent"];
|
||||
|
||||
const result = await this.localAuthService.login(dto.email, dto.password, ipAddress, userAgent);
|
||||
|
||||
return {
|
||||
user: result.user,
|
||||
session: result.session,
|
||||
};
|
||||
}
|
||||
|
||||
private getClientIp(req: ExpressRequest): string {
|
||||
const forwardedFor = req.headers["x-forwarded-for"];
|
||||
if (forwardedFor) {
|
||||
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
||||
return ips?.split(",")[0]?.trim() ?? "unknown";
|
||||
}
|
||||
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
||||
}
|
||||
}
|
||||
15
apps/api/src/auth/local/local-auth.guard.ts
Normal file
15
apps/api/src/auth/local/local-auth.guard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Injectable, CanActivate, NotFoundException } from "@nestjs/common";
|
||||
|
||||
/**
|
||||
* Guard that checks if local authentication is enabled via ENABLE_LOCAL_AUTH env var.
|
||||
* Returns 404 when disabled so endpoints are invisible to callers.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalAuthEnabledGuard implements CanActivate {
|
||||
canActivate(): boolean {
|
||||
if (process.env.ENABLE_LOCAL_AUTH !== "true") {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
389
apps/api/src/auth/local/local-auth.service.spec.ts
Normal file
389
apps/api/src/auth/local/local-auth.service.spec.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { hash } from "bcryptjs";
|
||||
import { LocalAuthService } from "./local-auth.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
describe("LocalAuthService", () => {
|
||||
let service: LocalAuthService;
|
||||
|
||||
const mockTxSession = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTxWorkspace = {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTxWorkspaceMember = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTxUser = {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTx = {
|
||||
user: mockTxUser,
|
||||
workspace: mockTxWorkspace,
|
||||
workspaceMember: mockTxWorkspaceMember,
|
||||
session: mockTxSession,
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi
|
||||
.fn()
|
||||
.mockImplementation((fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
|
||||
};
|
||||
|
||||
const originalEnv = {
|
||||
BREAKGLASS_SETUP_TOKEN: process.env.BREAKGLASS_SETUP_TOKEN,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LocalAuthService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LocalAuthService>(LocalAuthService);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalEnv.BREAKGLASS_SETUP_TOKEN !== undefined) {
|
||||
process.env.BREAKGLASS_SETUP_TOKEN = originalEnv.BREAKGLASS_SETUP_TOKEN;
|
||||
} else {
|
||||
delete process.env.BREAKGLASS_SETUP_TOKEN;
|
||||
}
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
const validSetupArgs = {
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
password: "securePassword123!",
|
||||
setupToken: "valid-token-123",
|
||||
};
|
||||
|
||||
const mockCreatedUser = {
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
isLocalAuth: true,
|
||||
createdAt: new Date("2026-02-28T00:00:00Z"),
|
||||
};
|
||||
|
||||
const mockWorkspace = {
|
||||
id: "workspace-uuid-123",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.BREAKGLASS_SETUP_TOKEN = "valid-token-123";
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
mockTxUser.create.mockResolvedValue(mockCreatedUser);
|
||||
mockTxWorkspace.findFirst.mockResolvedValue(mockWorkspace);
|
||||
mockTxWorkspaceMember.create.mockResolvedValue({});
|
||||
mockTxSession.create.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("should create a local auth user with hashed password", async () => {
|
||||
const result = await service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
);
|
||||
|
||||
expect(result.user).toEqual(mockCreatedUser);
|
||||
expect(result.session.token).toBeDefined();
|
||||
expect(result.session.token.length).toBeGreaterThan(0);
|
||||
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
||||
expect(result.session.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
|
||||
expect(mockTxUser.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
isLocalAuth: true,
|
||||
emailVerified: true,
|
||||
passwordHash: expect.any(String) as string,
|
||||
}),
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
isLocalAuth: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign OWNER role on default workspace", async () => {
|
||||
await service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
);
|
||||
|
||||
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: "workspace-uuid-123",
|
||||
userId: "user-uuid-123",
|
||||
role: "OWNER",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a new workspace if none exists", async () => {
|
||||
mockTxWorkspace.findFirst.mockResolvedValue(null);
|
||||
mockTxWorkspace.create.mockResolvedValue({ id: "new-workspace-uuid" });
|
||||
|
||||
await service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
);
|
||||
|
||||
expect(mockTxWorkspace.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Default Workspace",
|
||||
ownerId: "user-uuid-123",
|
||||
settings: {},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: "new-workspace-uuid",
|
||||
userId: "user-uuid-123",
|
||||
role: "OWNER",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a BetterAuth-compatible session", async () => {
|
||||
await service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken,
|
||||
"192.168.1.1",
|
||||
"TestAgent/1.0"
|
||||
);
|
||||
|
||||
expect(mockTxSession.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "user-uuid-123",
|
||||
token: expect.any(String) as string,
|
||||
expiresAt: expect.any(Date) as Date,
|
||||
ipAddress: "192.168.1.1",
|
||||
userAgent: "TestAgent/1.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject when BREAKGLASS_SETUP_TOKEN is not set", async () => {
|
||||
delete process.env.BREAKGLASS_SETUP_TOKEN;
|
||||
|
||||
await expect(
|
||||
service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
)
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should reject when BREAKGLASS_SETUP_TOKEN is empty", async () => {
|
||||
process.env.BREAKGLASS_SETUP_TOKEN = "";
|
||||
|
||||
await expect(
|
||||
service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
)
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should reject when setup token does not match", async () => {
|
||||
await expect(
|
||||
service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
"wrong-token"
|
||||
)
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should reject when email already exists", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
id: "existing-user",
|
||||
email: "admin@example.com",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
)
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("should return session token and expiry", async () => {
|
||||
const result = await service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
);
|
||||
|
||||
expect(typeof result.session.token).toBe("string");
|
||||
expect(result.session.token.length).toBe(64); // 32 bytes hex
|
||||
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
const validPasswordHash = "$2a$12$LJ3m4ys3Lz/YgP7xYz5k5uU6b5F6X1234567890abcdefghijkl";
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a real bcrypt hash for testing
|
||||
const realHash = await hash("securePassword123!", 4); // Low rounds for test speed
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
isLocalAuth: true,
|
||||
passwordHash: realHash,
|
||||
deactivatedAt: null,
|
||||
});
|
||||
mockPrismaService.session.create.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("should authenticate a valid local auth user", async () => {
|
||||
const result = await service.login("admin@example.com", "securePassword123!");
|
||||
|
||||
expect(result.user).toEqual({
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
});
|
||||
expect(result.session.token).toBeDefined();
|
||||
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should create a session with ip and user agent", async () => {
|
||||
await service.login("admin@example.com", "securePassword123!", "10.0.0.1", "Mozilla/5.0");
|
||||
|
||||
expect(mockPrismaService.session.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "user-uuid-123",
|
||||
token: expect.any(String) as string,
|
||||
expiresAt: expect.any(Date) as Date,
|
||||
ipAddress: "10.0.0.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject when user does not exist", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.login("nonexistent@example.com", "password123456")).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject when user is not a local auth user", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "OIDC User",
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
deactivatedAt: null,
|
||||
});
|
||||
|
||||
await expect(service.login("admin@example.com", "password123456")).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject when user is deactivated", async () => {
|
||||
const realHash = await hash("securePassword123!", 4);
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Deactivated User",
|
||||
isLocalAuth: true,
|
||||
passwordHash: realHash,
|
||||
deactivatedAt: new Date("2026-01-01"),
|
||||
});
|
||||
|
||||
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
|
||||
new UnauthorizedException("Account has been deactivated")
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject when password is incorrect", async () => {
|
||||
await expect(service.login("admin@example.com", "wrongPassword123!")).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw InternalServerError when local auth user has no password hash", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Broken User",
|
||||
isLocalAuth: true,
|
||||
passwordHash: null,
|
||||
deactivatedAt: null,
|
||||
});
|
||||
|
||||
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
|
||||
InternalServerErrorException
|
||||
);
|
||||
});
|
||||
|
||||
it("should not reveal whether email exists in error messages", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
try {
|
||||
await service.login("nonexistent@example.com", "password123456");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(UnauthorizedException);
|
||||
expect((error as UnauthorizedException).message).toBe("Invalid email or password");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
230
apps/api/src/auth/local/local-auth.service.ts
Normal file
230
apps/api/src/auth/local/local-auth.service.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import { hash, compare } from "bcryptjs";
|
||||
import { randomBytes, timingSafeEqual } from "crypto";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
/** Session expiry: 7 days (matches BetterAuth config in auth.config.ts) */
|
||||
const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface SetupResult {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isLocalAuth: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
session: {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
session: {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthService {
|
||||
private readonly logger = new Logger(LocalAuthService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* First-time break-glass user creation.
|
||||
* Validates the setup token, creates a local auth user with bcrypt-hashed password,
|
||||
* and assigns OWNER role on the default workspace.
|
||||
*/
|
||||
async setup(
|
||||
email: string,
|
||||
name: string,
|
||||
password: string,
|
||||
setupToken: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<SetupResult> {
|
||||
this.validateSetupToken(setupToken);
|
||||
|
||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
throw new ConflictException("A user with this email already exists");
|
||||
}
|
||||
|
||||
const passwordHash = await hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
isLocalAuth: true,
|
||||
passwordHash,
|
||||
emailVerified: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
isLocalAuth: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Find or create a default workspace and assign OWNER role
|
||||
await this.assignDefaultWorkspace(tx, user.id);
|
||||
|
||||
// Create a BetterAuth-compatible session
|
||||
const session = await this.createSession(tx, user.id, ipAddress, userAgent);
|
||||
|
||||
return { user, session };
|
||||
});
|
||||
|
||||
this.logger.log(`Break-glass user created: ${email}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Break-glass login: verify email + password against bcrypt hash.
|
||||
* Only works for users with isLocalAuth=true.
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<LoginResult> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
isLocalAuth: true,
|
||||
passwordHash: true,
|
||||
deactivatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.isLocalAuth) {
|
||||
throw new UnauthorizedException("Invalid email or password");
|
||||
}
|
||||
|
||||
if (user.deactivatedAt) {
|
||||
throw new UnauthorizedException("Account has been deactivated");
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
this.logger.error(`Local auth user ${email} has no password hash`);
|
||||
throw new InternalServerErrorException("Account configuration error");
|
||||
}
|
||||
|
||||
const passwordValid = await compare(password, user.passwordHash);
|
||||
if (!passwordValid) {
|
||||
throw new UnauthorizedException("Invalid email or password");
|
||||
}
|
||||
|
||||
const session = await this.createSession(this.prisma, user.id, ipAddress, userAgent);
|
||||
|
||||
this.logger.log(`Break-glass login: ${email}`);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, name: user.name },
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the setup token against the environment variable.
|
||||
*/
|
||||
private validateSetupToken(token: string): void {
|
||||
const expectedToken = process.env.BREAKGLASS_SETUP_TOKEN;
|
||||
|
||||
if (!expectedToken || expectedToken.trim() === "") {
|
||||
throw new ForbiddenException(
|
||||
"Break-glass setup is not configured. Set BREAKGLASS_SETUP_TOKEN environment variable."
|
||||
);
|
||||
}
|
||||
|
||||
const tokenBuffer = Buffer.from(token);
|
||||
const expectedBuffer = Buffer.from(expectedToken);
|
||||
if (
|
||||
tokenBuffer.length !== expectedBuffer.length ||
|
||||
!timingSafeEqual(tokenBuffer, expectedBuffer)
|
||||
) {
|
||||
this.logger.warn("Invalid break-glass setup token attempt");
|
||||
throw new ForbiddenException("Invalid setup token");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first workspace or create a default one, then assign OWNER role.
|
||||
*/
|
||||
private async assignDefaultWorkspace(
|
||||
tx: Parameters<Parameters<PrismaService["$transaction"]>[0]>[0],
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
let workspace = await tx.workspace.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
workspace ??= await tx.workspace.create({
|
||||
data: {
|
||||
name: "Default Workspace",
|
||||
ownerId: userId,
|
||||
settings: {},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
userId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BetterAuth-compatible session record.
|
||||
*/
|
||||
private async createSession(
|
||||
tx: { session: { create: typeof PrismaService.prototype.session.create } },
|
||||
userId: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
||||
|
||||
await tx.session.create({
|
||||
data: {
|
||||
userId,
|
||||
token,
|
||||
expiresAt,
|
||||
ipAddress: ipAddress ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
}
|
||||
@@ -110,10 +110,10 @@ export class WorkspaceGuard implements CanActivate {
|
||||
return paramWorkspaceId;
|
||||
}
|
||||
|
||||
// 3. Check request body
|
||||
const bodyWorkspaceId = request.body.workspaceId;
|
||||
if (typeof bodyWorkspaceId === "string") {
|
||||
return bodyWorkspaceId;
|
||||
// 3. Check request body (body may be undefined for GET requests despite Express typings)
|
||||
const body = request.body as Record<string, unknown> | undefined;
|
||||
if (body && typeof body.workspaceId === "string") {
|
||||
return body.workspaceId;
|
||||
}
|
||||
|
||||
// 4. Check query string (backward compatibility for existing clients)
|
||||
|
||||
@@ -270,7 +270,7 @@ describe("sanitizeForLogging", () => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(result.password).toBe("[REDACTED]");
|
||||
expect(duration).toBeLessThan(100); // Should complete in under 100ms
|
||||
expect(duration).toBeLessThan(500); // Should complete in under 500ms
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ describe("CoordinatorIntegrationController - Rate Limiting", () => {
|
||||
.set("X-API-Key", "test-coordinator-key");
|
||||
|
||||
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
});
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("Per-API-Key Rate Limiting", () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator";
|
||||
import { IsOptional, IsEnum, IsString, IsInt, IsIn, Min, Max } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { EntryStatus } from "@prisma/client";
|
||||
import { EntryStatus, Visibility } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* DTO for querying knowledge entries (list endpoint)
|
||||
@@ -10,10 +10,28 @@ export class EntryQueryDto {
|
||||
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||
status?: EntryStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(Visibility, { message: "visibility must be a valid Visibility" })
|
||||
visibility?: Visibility;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "tag must be a string" })
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "search must be a string" })
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["updatedAt", "createdAt", "title"], {
|
||||
message: "sortBy must be updatedAt, createdAt, or title",
|
||||
})
|
||||
sortBy?: "updatedAt" | "createdAt" | "title";
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" })
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "page must be an integer" })
|
||||
|
||||
@@ -48,6 +48,10 @@ export class KnowledgeService {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
if (query.visibility) {
|
||||
where.visibility = query.visibility;
|
||||
}
|
||||
|
||||
if (query.tag) {
|
||||
where.tags = {
|
||||
some: {
|
||||
@@ -58,6 +62,20 @@ export class KnowledgeService {
|
||||
};
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: query.search, mode: "insensitive" } },
|
||||
{ content: { contains: query.search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
// Build orderBy
|
||||
const sortField = query.sortBy ?? "updatedAt";
|
||||
const sortDirection = query.sortOrder ?? "desc";
|
||||
const orderBy: Prisma.KnowledgeEntryOrderByWithRelationInput = {
|
||||
[sortField]: sortDirection,
|
||||
};
|
||||
|
||||
// Get total count
|
||||
const total = await this.prisma.knowledgeEntry.count({ where });
|
||||
|
||||
@@ -71,9 +89,7 @@ export class KnowledgeService {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
orderBy,
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
@@ -1,59 +1,38 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from "class-validator";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for creating a new personality/assistant configuration
|
||||
* DTO for creating a new personality
|
||||
* Field names match the frontend API contract from @mosaic/shared Personality type.
|
||||
*/
|
||||
export class CreatePersonalityDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name!: string; // unique identifier slug
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
displayName!: string; // human-readable name
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
systemPrompt!: string;
|
||||
@IsString({ message: "tone must be a string" })
|
||||
@MinLength(1, { message: "tone must not be empty" })
|
||||
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
||||
tone!: string;
|
||||
|
||||
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
||||
formalityLevel!: FormalityLevel;
|
||||
|
||||
@IsString({ message: "systemPromptTemplate must be a string" })
|
||||
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
||||
systemPromptTemplate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(2)
|
||||
temperature?: number; // null = use provider default
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxTokens?: number; // null = use provider default
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4")
|
||||
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./create-personality.dto";
|
||||
export * from "./update-personality.dto";
|
||||
export * from "./personality-query.dto";
|
||||
|
||||
12
apps/api/src/personalities/dto/personality-query.dto.ts
Normal file
12
apps/api/src/personalities/dto/personality-query.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsBoolean, IsOptional } from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
|
||||
/**
|
||||
* DTO for querying/filtering personalities
|
||||
*/
|
||||
export class PersonalityQueryDto {
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
@Transform(({ value }) => value === "true" || value === true)
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -1,62 +1,42 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from "class-validator";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for updating an existing personality/assistant configuration
|
||||
* DTO for updating an existing personality
|
||||
* All fields are optional; only provided fields are updated.
|
||||
*/
|
||||
export class UpdatePersonalityDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name?: string; // unique identifier slug
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
displayName?: string; // human-readable name
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
systemPrompt?: string;
|
||||
@IsString({ message: "tone must be a string" })
|
||||
@MinLength(1, { message: "tone must not be empty" })
|
||||
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
||||
tone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(2)
|
||||
temperature?: number; // null = use provider default
|
||||
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
||||
formalityLevel?: FormalityLevel;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxTokens?: number; // null = use provider default
|
||||
@IsString({ message: "systemPromptTemplate must be a string" })
|
||||
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
||||
systemPromptTemplate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4")
|
||||
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import type { Personality as PrismaPersonality } from "@prisma/client";
|
||||
import type { FormalityLevel } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Personality entity representing an assistant configuration
|
||||
* Personality response entity
|
||||
* Maps Prisma Personality fields to the frontend API contract.
|
||||
*
|
||||
* Field mapping (Prisma -> API):
|
||||
* systemPrompt -> systemPromptTemplate
|
||||
* isEnabled -> isActive
|
||||
* (tone, formalityLevel are identical in both)
|
||||
*/
|
||||
export class Personality implements PrismaPersonality {
|
||||
id!: string;
|
||||
workspaceId!: string;
|
||||
name!: string; // unique identifier slug
|
||||
displayName!: string; // human-readable name
|
||||
description!: string | null;
|
||||
systemPrompt!: string;
|
||||
temperature!: number | null; // null = use provider default
|
||||
maxTokens!: number | null; // null = use provider default
|
||||
llmProviderInstanceId!: string | null; // FK to LlmProviderInstance
|
||||
isDefault!: boolean;
|
||||
isEnabled!: boolean;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
export interface PersonalityResponse {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tone: string;
|
||||
formalityLevel: FormalityLevel;
|
||||
systemPromptTemplate: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -2,36 +2,32 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PersonalitiesController } from "./personalities.controller";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
|
||||
describe("PersonalitiesController", () => {
|
||||
let controller: PersonalitiesController;
|
||||
let service: PersonalitiesService;
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockUserId = "user-123";
|
||||
const mockPersonalityId = "personality-123";
|
||||
|
||||
/** API response shape (frontend field names) */
|
||||
const mockPersonality = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
displayName: "Professional Assistant",
|
||||
description: "A professional communication assistant",
|
||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
llmProviderInstanceId: "provider-123",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
user: { id: mockUserId },
|
||||
workspaceId: mockWorkspaceId,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
const mockPersonalitiesService = {
|
||||
@@ -57,46 +53,43 @@ describe("PersonalitiesController", () => {
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue({
|
||||
canActivate: (ctx: {
|
||||
switchToHttp: () => { getRequest: () => { workspaceId: string } };
|
||||
}) => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
req.workspaceId = mockWorkspaceId;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all personalities", async () => {
|
||||
const mockPersonalities = [mockPersonality];
|
||||
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
|
||||
it("should return success response with personalities list", async () => {
|
||||
const mockList = [mockPersonality];
|
||||
mockPersonalitiesService.findAll.mockResolvedValue(mockList);
|
||||
|
||||
const result = await controller.findAll(mockRequest);
|
||||
const result = await controller.findAll(mockWorkspaceId, {});
|
||||
|
||||
expect(result).toEqual(mockPersonalities);
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
expect(result).toEqual({ success: true, data: mockList });
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||
it("should pass isActive query filter to service", async () => {
|
||||
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
|
||||
|
||||
const result = await controller.findOne(mockRequest, mockPersonalityId);
|
||||
await controller.findAll(mockWorkspaceId, { isActive: true });
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByName", () => {
|
||||
it("should return a personality by name", async () => {
|
||||
mockPersonalitiesService.findByName.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findByName(mockRequest, "professional-assistant");
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findByName).toHaveBeenCalledWith(mockWorkspaceId, "professional-assistant");
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,32 +97,40 @@ describe("PersonalitiesController", () => {
|
||||
it("should return the default personality", async () => {
|
||||
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findDefault(mockRequest);
|
||||
const result = await controller.findDefault(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findOne(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a new personality", async () => {
|
||||
const createDto: CreatePersonalityDto = {
|
||||
name: "casual-helper",
|
||||
displayName: "Casual Helper",
|
||||
description: "A casual helper",
|
||||
systemPrompt: "You are a casual assistant.",
|
||||
temperature: 0.8,
|
||||
maxTokens: 1500,
|
||||
tone: "casual",
|
||||
formalityLevel: FormalityLevel.CASUAL,
|
||||
systemPromptTemplate: "You are a casual assistant.",
|
||||
};
|
||||
|
||||
mockPersonalitiesService.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDto,
|
||||
});
|
||||
const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false };
|
||||
mockPersonalitiesService.create.mockResolvedValue(created);
|
||||
|
||||
const result = await controller.create(mockRequest, createDto);
|
||||
const result = await controller.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toMatchObject(createDto);
|
||||
expect(result).toMatchObject({ name: createDto.name, tone: createDto.tone });
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||
});
|
||||
});
|
||||
@@ -138,15 +139,13 @@ describe("PersonalitiesController", () => {
|
||||
it("should update a personality", async () => {
|
||||
const updateDto: UpdatePersonalityDto = {
|
||||
description: "Updated description",
|
||||
temperature: 0.9,
|
||||
tone: "enthusiastic",
|
||||
};
|
||||
|
||||
mockPersonalitiesService.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...updateDto,
|
||||
});
|
||||
const updated = { ...mockPersonality, ...updateDto };
|
||||
mockPersonalitiesService.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await controller.update(mockRequest, mockPersonalityId, updateDto);
|
||||
const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
|
||||
expect(result).toMatchObject(updateDto);
|
||||
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
@@ -157,7 +156,7 @@ describe("PersonalitiesController", () => {
|
||||
it("should delete a personality", async () => {
|
||||
mockPersonalitiesService.delete.mockResolvedValue(undefined);
|
||||
|
||||
await controller.delete(mockRequest, mockPersonalityId);
|
||||
await controller.delete(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
@@ -165,12 +164,10 @@ describe("PersonalitiesController", () => {
|
||||
|
||||
describe("setDefault", () => {
|
||||
it("should set a personality as default", async () => {
|
||||
mockPersonalitiesService.setDefault.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
isDefault: true,
|
||||
});
|
||||
const updated = { ...mockPersonality, isDefault: true };
|
||||
mockPersonalitiesService.setDefault.mockResolvedValue(updated);
|
||||
|
||||
const result = await controller.setDefault(mockRequest, mockPersonalityId);
|
||||
const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toMatchObject({ isDefault: true });
|
||||
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
@@ -6,105 +6,122 @@ import {
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { Personality } from "./entities/personality.entity";
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
workspaceId: string;
|
||||
}
|
||||
import { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { PersonalityQueryDto } from "./dto/personality-query.dto";
|
||||
import type { PersonalityResponse } from "./entities/personality.entity";
|
||||
|
||||
/**
|
||||
* Controller for managing personality/assistant configurations
|
||||
* Controller for personality CRUD endpoints.
|
||||
* Route: /api/personalities
|
||||
*
|
||||
* Guards applied in order:
|
||||
* 1. AuthGuard - verifies the user is authenticated
|
||||
* 2. WorkspaceGuard - validates workspace access
|
||||
* 3. PermissionGuard - checks role-based permissions
|
||||
*/
|
||||
@Controller("personality")
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller("personalities")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class PersonalitiesController {
|
||||
constructor(private readonly personalitiesService: PersonalitiesService) {}
|
||||
|
||||
/**
|
||||
* List all personalities for the workspace
|
||||
* GET /api/personalities
|
||||
* List all personalities for the workspace.
|
||||
* Supports ?isActive=true|false filter.
|
||||
*/
|
||||
@Get()
|
||||
async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> {
|
||||
return this.personalitiesService.findAll(req.workspaceId);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(
|
||||
@Workspace() workspaceId: string,
|
||||
@Query() query: PersonalityQueryDto
|
||||
): Promise<{ success: true; data: PersonalityResponse[] }> {
|
||||
const data = await this.personalitiesService.findAll(workspaceId, query);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default personality for the workspace
|
||||
* GET /api/personalities/default
|
||||
* Get the default personality for the workspace.
|
||||
* Must be declared before :id to avoid route conflicts.
|
||||
*/
|
||||
@Get("default")
|
||||
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
|
||||
return this.personalitiesService.findDefault(req.workspaceId);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findDefault(@Workspace() workspaceId: string): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.findDefault(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a personality by its unique name
|
||||
*/
|
||||
@Get("by-name/:name")
|
||||
async findByName(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("name") name: string
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.findByName(req.workspaceId, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a personality by ID
|
||||
* GET /api/personalities/:id
|
||||
* Get a single personality by ID.
|
||||
*/
|
||||
@Get(":id")
|
||||
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
|
||||
return this.personalitiesService.findOne(req.workspaceId, id);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findOne(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.findOne(workspaceId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personality
|
||||
* POST /api/personalities
|
||||
* Create a new personality.
|
||||
*/
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Body() dto: CreatePersonalityDto
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.create(req.workspaceId, dto);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.create(workspaceId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a personality
|
||||
* PATCH /api/personalities/:id
|
||||
* Update an existing personality.
|
||||
*/
|
||||
@Patch(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdatePersonalityDto
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.update(req.workspaceId, id, dto);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.update(workspaceId, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
* DELETE /api/personalities/:id
|
||||
* Delete a personality.
|
||||
*/
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
|
||||
return this.personalitiesService.delete(req.workspaceId, id);
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise<void> {
|
||||
return this.personalitiesService.delete(workspaceId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a personality as the default
|
||||
* POST /api/personalities/:id/set-default
|
||||
* Convenience endpoint to set a personality as the default.
|
||||
*/
|
||||
@Post(":id/set-default")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async setDefault(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.setDefault(req.workspaceId, id);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.setDefault(workspaceId, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { NotFoundException, ConflictException } from "@nestjs/common";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
|
||||
describe("PersonalitiesService", () => {
|
||||
let service: PersonalitiesService;
|
||||
@@ -11,22 +13,39 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockPersonalityId = "personality-123";
|
||||
const mockProviderId = "provider-123";
|
||||
|
||||
const mockPersonality = {
|
||||
/** Raw Prisma record shape (uses Prisma field names) */
|
||||
const mockPrismaRecord = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
displayName: "Professional Assistant",
|
||||
description: "A professional communication assistant",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
llmProviderInstanceId: mockProviderId,
|
||||
llmProviderInstanceId: "provider-123",
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
/** Expected API response shape (uses frontend field names) */
|
||||
const mockResponse = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
description: "A professional communication assistant",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
@@ -37,9 +56,7 @@ describe("PersonalitiesService", () => {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((callback) => callback(mockPrismaService)),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -56,44 +73,54 @@ describe("PersonalitiesService", () => {
|
||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
const createDto: CreatePersonalityDto = {
|
||||
name: "casual-helper",
|
||||
displayName: "Casual Helper",
|
||||
description: "A casual communication helper",
|
||||
systemPrompt: "You are a casual assistant.",
|
||||
temperature: 0.8,
|
||||
maxTokens: 1500,
|
||||
llmProviderInstanceId: mockProviderId,
|
||||
tone: "casual",
|
||||
formalityLevel: FormalityLevel.CASUAL,
|
||||
systemPromptTemplate: "You are a casual assistant.",
|
||||
isDefault: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
it("should create a new personality", async () => {
|
||||
const createdRecord = {
|
||||
...mockPrismaRecord,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
tone: createDto.tone,
|
||||
formalityLevel: createDto.formalityLevel,
|
||||
systemPrompt: createDto.systemPromptTemplate,
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
id: "new-personality-id",
|
||||
};
|
||||
|
||||
it("should create a new personality and return API response shape", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.personality.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDto,
|
||||
id: "new-personality-id",
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
});
|
||||
mockPrismaService.personality.create.mockResolvedValue(createdRecord);
|
||||
|
||||
const result = await service.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toMatchObject(createDto);
|
||||
expect(result.name).toBe(createDto.name);
|
||||
expect(result.tone).toBe(createDto.tone);
|
||||
expect(result.formalityLevel).toBe(createDto.formalityLevel);
|
||||
expect(result.systemPromptTemplate).toBe(createDto.systemPromptTemplate);
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(result.isDefault).toBe(false);
|
||||
|
||||
expect(prisma.personality.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
displayName: createDto.displayName,
|
||||
displayName: createDto.name,
|
||||
description: createDto.description ?? null,
|
||||
systemPrompt: createDto.systemPrompt,
|
||||
temperature: createDto.temperature ?? null,
|
||||
maxTokens: createDto.maxTokens ?? null,
|
||||
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
|
||||
tone: createDto.tone,
|
||||
formalityLevel: createDto.formalityLevel,
|
||||
systemPrompt: createDto.systemPromptTemplate,
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
@@ -101,68 +128,73 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should throw ConflictException when name already exists", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("should unset other defaults when creating a new default personality", async () => {
|
||||
const createDefaultDto = { ...createDto, isDefault: true };
|
||||
// First call to findFirst checks for name conflict (should be null)
|
||||
// Second call to findFirst finds the existing default personality
|
||||
const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true };
|
||||
const otherDefault = { ...mockPrismaRecord, id: "other-id" };
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(null) // No name conflict
|
||||
.mockResolvedValueOnce(mockPersonality); // Existing default
|
||||
mockPrismaService.personality.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
isDefault: false,
|
||||
});
|
||||
.mockResolvedValueOnce(null) // name conflict check
|
||||
.mockResolvedValueOnce(otherDefault); // existing default lookup
|
||||
mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false });
|
||||
mockPrismaService.personality.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDefaultDto,
|
||||
...createdRecord,
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
await service.create(mockWorkspaceId, createDefaultDto);
|
||||
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all personalities for a workspace", async () => {
|
||||
const mockPersonalities = [mockPersonality];
|
||||
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
|
||||
it("should return mapped response list for a workspace", async () => {
|
||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
||||
|
||||
const result = await service.findAll(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonalities);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(mockResponse);
|
||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by isActive when provided", async () => {
|
||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
||||
|
||||
await service.findAll(mockWorkspaceId, { isActive: true });
|
||||
|
||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isEnabled: true },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
it("should return a mapped personality response by id", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId, workspaceId: mockWorkspaceId },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
@@ -171,17 +203,14 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
describe("findByName", () => {
|
||||
it("should return a personality by name", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
it("should return a mapped personality response by name", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
},
|
||||
where: { workspaceId: mockWorkspaceId, name: "professional-assistant" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,11 +225,11 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
describe("findDefault", () => {
|
||||
it("should return the default personality", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findDefault(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
|
||||
});
|
||||
@@ -216,41 +245,45 @@ describe("PersonalitiesService", () => {
|
||||
describe("update", () => {
|
||||
const updateDto: UpdatePersonalityDto = {
|
||||
description: "Updated description",
|
||||
temperature: 0.9,
|
||||
tone: "formal",
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
it("should update a personality", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.personality.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...updateDto,
|
||||
});
|
||||
it("should update a personality and return mapped response", async () => {
|
||||
const updatedRecord = {
|
||||
...mockPrismaRecord,
|
||||
description: updateDto.description,
|
||||
tone: updateDto.tone,
|
||||
isEnabled: false,
|
||||
};
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(null); // name conflict check (no dto.name here)
|
||||
mockPrismaService.personality.update.mockResolvedValue(updatedRecord);
|
||||
|
||||
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
|
||||
expect(result).toMatchObject(updateDto);
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
data: updateDto,
|
||||
});
|
||||
expect(result.description).toBe(updateDto.description);
|
||||
expect(result.tone).toBe(updateDto.tone);
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ConflictException when updating to existing name", async () => {
|
||||
const updateNameDto = { name: "existing-name" };
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
id: "different-id",
|
||||
});
|
||||
it("should throw ConflictException when updating to an existing name", async () => {
|
||||
const updateNameDto: UpdatePersonalityDto = { name: "existing-name" };
|
||||
const conflictRecord = { ...mockPrismaRecord, id: "different-id" };
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(conflictRecord); // name conflict
|
||||
|
||||
await expect(
|
||||
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
|
||||
@@ -258,14 +291,16 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should unset other defaults when setting as default", async () => {
|
||||
const updateDefaultDto = { isDefault: true };
|
||||
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
||||
const updateDefaultDto: UpdatePersonalityDto = { isDefault: true };
|
||||
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
||||
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
||||
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
||||
mockPrismaService.personality.update
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
||||
.mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
||||
.mockResolvedValueOnce(updatedRecord);
|
||||
|
||||
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
|
||||
|
||||
@@ -273,16 +308,12 @@ describe("PersonalitiesService", () => {
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
||||
where: { id: mockPersonalityId },
|
||||
data: updateDefaultDto,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete a personality", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
mockPrismaService.personality.delete.mockResolvedValue(undefined);
|
||||
|
||||
await service.delete(mockWorkspaceId, mockPersonalityId);
|
||||
@@ -293,7 +324,7 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
@@ -303,30 +334,27 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
describe("setDefault", () => {
|
||||
it("should set a personality as default", async () => {
|
||||
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
||||
const updatedPersonality = { ...mockPersonality, isDefault: true };
|
||||
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
||||
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
||||
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
||||
mockPrismaService.personality.update
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
||||
.mockResolvedValueOnce(updatedPersonality); // Set new default
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
||||
.mockResolvedValueOnce(updatedRecord);
|
||||
|
||||
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toMatchObject({ isDefault: true });
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
||||
expect(result.isDefault).toBe(true);
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
|
||||
import type { FormalityLevel, Personality } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { Personality } from "./entities/personality.entity";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import type { PersonalityQueryDto } from "./dto/personality-query.dto";
|
||||
import type { PersonalityResponse } from "./entities/personality.entity";
|
||||
|
||||
/**
|
||||
* Service for managing personality/assistant configurations
|
||||
* Service for managing personality/assistant configurations.
|
||||
*
|
||||
* Field mapping:
|
||||
* Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate`
|
||||
* Prisma `isEnabled` <-> API/frontend `isActive`
|
||||
*/
|
||||
@Injectable()
|
||||
export class PersonalitiesService {
|
||||
@@ -12,11 +19,30 @@ export class PersonalitiesService {
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Map a Prisma Personality record to the API response shape.
|
||||
*/
|
||||
private toResponse(personality: Personality): PersonalityResponse {
|
||||
return {
|
||||
id: personality.id,
|
||||
workspaceId: personality.workspaceId,
|
||||
name: personality.name,
|
||||
description: personality.description,
|
||||
tone: personality.tone,
|
||||
formalityLevel: personality.formalityLevel,
|
||||
systemPromptTemplate: personality.systemPrompt,
|
||||
isDefault: personality.isDefault,
|
||||
isActive: personality.isEnabled,
|
||||
createdAt: personality.createdAt,
|
||||
updatedAt: personality.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personality
|
||||
*/
|
||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
|
||||
// Check for duplicate name
|
||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<PersonalityResponse> {
|
||||
// Check for duplicate name within workspace
|
||||
const existing = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, name: dto.name },
|
||||
});
|
||||
@@ -25,7 +51,7 @@ export class PersonalitiesService {
|
||||
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
|
||||
}
|
||||
|
||||
// If creating a default personality, unset other defaults
|
||||
// If creating as default, unset other defaults first
|
||||
if (dto.isDefault) {
|
||||
await this.unsetOtherDefaults(workspaceId);
|
||||
}
|
||||
@@ -34,36 +60,43 @@ export class PersonalitiesService {
|
||||
data: {
|
||||
workspaceId,
|
||||
name: dto.name,
|
||||
displayName: dto.displayName,
|
||||
displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
|
||||
description: dto.description ?? null,
|
||||
systemPrompt: dto.systemPrompt,
|
||||
temperature: dto.temperature ?? null,
|
||||
maxTokens: dto.maxTokens ?? null,
|
||||
llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
|
||||
tone: dto.tone,
|
||||
formalityLevel: dto.formalityLevel,
|
||||
systemPrompt: dto.systemPromptTemplate,
|
||||
isDefault: dto.isDefault ?? false,
|
||||
isEnabled: dto.isEnabled ?? true,
|
||||
isEnabled: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all personalities for a workspace
|
||||
* Find all personalities for a workspace with optional active filter
|
||||
*/
|
||||
async findAll(workspaceId: string): Promise<Personality[]> {
|
||||
return this.prisma.personality.findMany({
|
||||
where: { workspaceId },
|
||||
async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise<PersonalityResponse[]> {
|
||||
const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId };
|
||||
|
||||
if (query?.isActive !== undefined) {
|
||||
where.isEnabled = query.isActive;
|
||||
}
|
||||
|
||||
const personalities = await this.prisma.personality.findMany({
|
||||
where,
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
return personalities.map((p) => this.toResponse(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific personality by ID
|
||||
*/
|
||||
async findOne(workspaceId: string, id: string): Promise<Personality> {
|
||||
const personality = await this.prisma.personality.findUnique({
|
||||
async findOne(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
@@ -71,13 +104,13 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`Personality with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a personality by name
|
||||
* Find a personality by name slug
|
||||
*/
|
||||
async findByName(workspaceId: string, name: string): Promise<Personality> {
|
||||
async findByName(workspaceId: string, name: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, name },
|
||||
});
|
||||
@@ -86,13 +119,13 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`Personality with name "${name}" not found`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the default personality for a workspace
|
||||
* Find the default (and enabled) personality for a workspace
|
||||
*/
|
||||
async findDefault(workspaceId: string): Promise<Personality> {
|
||||
async findDefault(workspaceId: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, isDefault: true, isEnabled: true },
|
||||
});
|
||||
@@ -101,14 +134,18 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing personality
|
||||
*/
|
||||
async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise<Personality> {
|
||||
// Check existence
|
||||
async update(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
dto: UpdatePersonalityDto
|
||||
): Promise<PersonalityResponse> {
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
// Check for duplicate name if updating name
|
||||
@@ -127,20 +164,43 @@ export class PersonalitiesService {
|
||||
await this.unsetOtherDefaults(workspaceId, id);
|
||||
}
|
||||
|
||||
// Build update data with field mapping
|
||||
const updateData: {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
tone?: string;
|
||||
formalityLevel?: FormalityLevel;
|
||||
systemPrompt?: string;
|
||||
isDefault?: boolean;
|
||||
isEnabled?: boolean;
|
||||
} = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
updateData.name = dto.name;
|
||||
updateData.displayName = dto.name;
|
||||
}
|
||||
if (dto.description !== undefined) updateData.description = dto.description;
|
||||
if (dto.tone !== undefined) updateData.tone = dto.tone;
|
||||
if (dto.formalityLevel !== undefined) updateData.formalityLevel = dto.formalityLevel;
|
||||
if (dto.systemPromptTemplate !== undefined) updateData.systemPrompt = dto.systemPromptTemplate;
|
||||
if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault;
|
||||
if (dto.isActive !== undefined) updateData.isEnabled = dto.isActive;
|
||||
|
||||
const personality = await this.prisma.personality.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
*/
|
||||
async delete(workspaceId: string, id: string): Promise<void> {
|
||||
// Check existence
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
await this.prisma.personality.delete({
|
||||
@@ -151,23 +211,22 @@ export class PersonalitiesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a personality as the default
|
||||
* Set a personality as the default (convenience endpoint)
|
||||
*/
|
||||
async setDefault(workspaceId: string, id: string): Promise<Personality> {
|
||||
// Check existence
|
||||
async setDefault(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
// Unset other defaults
|
||||
await this.unsetOtherDefaults(workspaceId, id);
|
||||
|
||||
// Set this one as default
|
||||
const personality = await this.prisma.personality.update({
|
||||
where: { id },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
|
||||
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +237,7 @@ export class PersonalitiesService {
|
||||
where: {
|
||||
workspaceId,
|
||||
isDefault: true,
|
||||
...(excludeId && { id: { not: excludeId } }),
|
||||
...(excludeId !== undefined && { id: { not: excludeId } }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -140,8 +140,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
workspaceId: string,
|
||||
client: PrismaClient = this
|
||||
): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
||||
// Use set_config() instead of SET LOCAL so values are safely parameterized.
|
||||
// SET LOCAL with Prisma's tagged template produces invalid SQL (bind parameter $1
|
||||
// is not supported in SET statements by PostgreSQL).
|
||||
await client.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,8 +154,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
* @param client - Optional Prisma client (uses 'this' if not provided)
|
||||
*/
|
||||
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_user_id', '', true)`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_workspace_id', '', true)`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
13
apps/api/src/teams/dto/create-team.dto.ts
Normal file
13
apps/api/src/teams/dto/create-team.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class CreateTeamDto {
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
|
||||
description?: string;
|
||||
}
|
||||
11
apps/api/src/teams/dto/manage-team-member.dto.ts
Normal file
11
apps/api/src/teams/dto/manage-team-member.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { TeamMemberRole } from "@prisma/client";
|
||||
import { IsEnum, IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
export class ManageTeamMemberDto {
|
||||
@IsUUID("4", { message: "userId must be a valid UUID" })
|
||||
userId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TeamMemberRole, { message: "role must be a valid TeamMemberRole" })
|
||||
role?: TeamMemberRole;
|
||||
}
|
||||
150
apps/api/src/teams/teams.controller.spec.ts
Normal file
150
apps/api/src/teams/teams.controller.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { TeamMemberRole } from "@prisma/client";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { PermissionGuard, WorkspaceGuard } from "../common/guards";
|
||||
import { TeamsController } from "./teams.controller";
|
||||
import { TeamsService } from "./teams.service";
|
||||
|
||||
describe("TeamsController", () => {
|
||||
let controller: TeamsController;
|
||||
let service: TeamsService;
|
||||
|
||||
const mockTeamsService = {
|
||||
create: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
addMember: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockTeamId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
providers: [
|
||||
{
|
||||
provide: TeamsService,
|
||||
useValue: mockTeamsService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<TeamsController>(TeamsController);
|
||||
service = module.get<TeamsService>(TeamsService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a team in a workspace", async () => {
|
||||
const createDto = {
|
||||
name: "Platform Team",
|
||||
description: "Owns platform services",
|
||||
};
|
||||
|
||||
const createdTeam = {
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockTeamsService.create.mockResolvedValue(createdTeam);
|
||||
|
||||
const result = await controller.create(createDto, mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(createdTeam);
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should list teams in a workspace", async () => {
|
||||
const teams = [
|
||||
{
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "Platform Team",
|
||||
description: "Owns platform services",
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_count: { members: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
mockTeamsService.findAll.mockResolvedValue(teams);
|
||||
|
||||
const result = await controller.findAll(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(teams);
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMember", () => {
|
||||
it("should add a member to a team", async () => {
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const createdTeamMember = {
|
||||
teamId: mockTeamId,
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
joinedAt: new Date(),
|
||||
user: {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
mockTeamsService.addMember.mockResolvedValue(createdTeamMember);
|
||||
|
||||
const result = await controller.addMember(mockTeamId, dto, mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(createdTeamMember);
|
||||
expect(service.addMember).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId, dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeMember", () => {
|
||||
it("should remove a member from a team", async () => {
|
||||
mockTeamsService.removeMember.mockResolvedValue(undefined);
|
||||
|
||||
await controller.removeMember(mockTeamId, mockUserId, mockWorkspaceId);
|
||||
|
||||
expect(service.removeMember).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId, mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should delete a team", async () => {
|
||||
mockTeamsService.remove.mockResolvedValue(undefined);
|
||||
|
||||
await controller.remove(mockTeamId, mockWorkspaceId);
|
||||
|
||||
expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId);
|
||||
});
|
||||
});
|
||||
});
|
||||
51
apps/api/src/teams/teams.controller.ts
Normal file
51
apps/api/src/teams/teams.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { PermissionGuard, WorkspaceGuard } from "../common/guards";
|
||||
import { Permission, RequirePermission, Workspace } from "../common/decorators";
|
||||
import { CreateTeamDto } from "./dto/create-team.dto";
|
||||
import { ManageTeamMemberDto } from "./dto/manage-team-member.dto";
|
||||
import { TeamsService } from "./teams.service";
|
||||
|
||||
@Controller("workspaces/:workspaceId/teams")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teamsService: TeamsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async create(@Body() createTeamDto: CreateTeamDto, @Workspace() workspaceId: string) {
|
||||
return this.teamsService.create(workspaceId, createTeamDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(@Workspace() workspaceId: string) {
|
||||
return this.teamsService.findAll(workspaceId);
|
||||
}
|
||||
|
||||
@Post(":teamId/members")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async addMember(
|
||||
@Param("teamId") teamId: string,
|
||||
@Body() dto: ManageTeamMemberDto,
|
||||
@Workspace() workspaceId: string
|
||||
) {
|
||||
return this.teamsService.addMember(workspaceId, teamId, dto);
|
||||
}
|
||||
|
||||
@Delete(":teamId/members/:userId")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async removeMember(
|
||||
@Param("teamId") teamId: string,
|
||||
@Param("userId") userId: string,
|
||||
@Workspace() workspaceId: string
|
||||
) {
|
||||
return this.teamsService.removeMember(workspaceId, teamId, userId);
|
||||
}
|
||||
|
||||
@Delete(":teamId")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async remove(@Param("teamId") teamId: string, @Workspace() workspaceId: string) {
|
||||
return this.teamsService.remove(workspaceId, teamId);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/teams/teams.module.ts
Normal file
13
apps/api/src/teams/teams.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { TeamsController } from "./teams.controller";
|
||||
import { TeamsService } from "./teams.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [TeamsController],
|
||||
providers: [TeamsService],
|
||||
exports: [TeamsService],
|
||||
})
|
||||
export class TeamsModule {}
|
||||
286
apps/api/src/teams/teams.service.spec.ts
Normal file
286
apps/api/src/teams/teams.service.spec.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { TeamMemberRole } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { TeamsService } from "./teams.service";
|
||||
|
||||
describe("TeamsService", () => {
|
||||
let service: TeamsService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
team: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
workspaceMember: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
teamMember: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockTeamId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TeamsService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TeamsService>(TeamsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a team", async () => {
|
||||
const createDto = {
|
||||
name: "Platform Team",
|
||||
description: "Owns platform services",
|
||||
};
|
||||
|
||||
const createdTeam = {
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.team.create.mockResolvedValue(createdTeam);
|
||||
|
||||
const result = await service.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toEqual(createdTeam);
|
||||
expect(prisma.team.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should list teams for a workspace", async () => {
|
||||
const teams = [
|
||||
{
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "Platform Team",
|
||||
description: "Owns platform services",
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_count: { members: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
mockPrismaService.team.findMany.mockResolvedValue(teams);
|
||||
|
||||
const result = await service.findAll(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(teams);
|
||||
expect(prisma.team.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { members: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMember", () => {
|
||||
it("should add a workspace member to a team", async () => {
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const createdTeamMember = {
|
||||
teamId: mockTeamId,
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
joinedAt: new Date(),
|
||||
user: {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||
mockPrismaService.teamMember.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.teamMember.create.mockResolvedValue(createdTeamMember);
|
||||
|
||||
const result = await service.addMember(mockWorkspaceId, mockTeamId, dto);
|
||||
|
||||
expect(result).toEqual(createdTeamMember);
|
||||
expect(prisma.team.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.workspaceMember.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
expect(prisma.teamMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
teamId: mockTeamId,
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use MEMBER role when role is omitted", async () => {
|
||||
const dto = { userId: mockUserId };
|
||||
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||
mockPrismaService.teamMember.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.teamMember.create.mockResolvedValue({
|
||||
teamId: mockTeamId,
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.MEMBER,
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.addMember(mockWorkspaceId, mockTeamId, dto);
|
||||
|
||||
expect(prisma.teamMember.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
role: TeamMemberRole.MEMBER,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when team does not belong to workspace", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
|
||||
).rejects.toThrow(NotFoundException);
|
||||
expect(prisma.workspaceMember.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw when user is not a workspace member", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("should throw when user is already in the team", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||
mockPrismaService.teamMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||
|
||||
await expect(
|
||||
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeMember", () => {
|
||||
it("should remove a member from a team", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.teamMember.deleteMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
await service.removeMember(mockWorkspaceId, mockTeamId, mockUserId);
|
||||
|
||||
expect(prisma.teamMember.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
teamId: mockTeamId,
|
||||
userId: mockUserId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw when team does not belong to workspace", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.removeMember(mockWorkspaceId, mockTeamId, mockUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
expect(prisma.teamMember.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw when user is not in the team", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.teamMember.deleteMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
await expect(service.removeMember(mockWorkspaceId, mockTeamId, mockUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should delete a team", async () => {
|
||||
mockPrismaService.team.deleteMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
await service.remove(mockWorkspaceId, mockTeamId);
|
||||
|
||||
expect(prisma.team.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw when team is not found", async () => {
|
||||
mockPrismaService.team.deleteMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
await expect(service.remove(mockWorkspaceId, mockTeamId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
130
apps/api/src/teams/teams.service.ts
Normal file
130
apps/api/src/teams/teams.service.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { TeamMemberRole } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateTeamDto } from "./dto/create-team.dto";
|
||||
import { ManageTeamMemberDto } from "./dto/manage-team-member.dto";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(workspaceId: string, createTeamDto: CreateTeamDto) {
|
||||
return this.prisma.team.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
name: createTeamDto.name,
|
||||
description: createTeamDto.description ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(workspaceId: string) {
|
||||
return this.prisma.team.findMany({
|
||||
where: { workspaceId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { members: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async addMember(workspaceId: string, teamId: string, dto: ManageTeamMemberDto) {
|
||||
await this.ensureTeamInWorkspace(workspaceId, teamId);
|
||||
|
||||
const workspaceMember = await this.prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: dto.userId,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!workspaceMember) {
|
||||
throw new BadRequestException(
|
||||
`User ${dto.userId} must be a workspace member before being added to a team`
|
||||
);
|
||||
}
|
||||
|
||||
const existingTeamMember = await this.prisma.teamMember.findUnique({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId: dto.userId,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (existingTeamMember) {
|
||||
throw new ConflictException(`User ${dto.userId} is already a member of team ${teamId}`);
|
||||
}
|
||||
|
||||
return this.prisma.teamMember.create({
|
||||
data: {
|
||||
teamId,
|
||||
userId: dto.userId,
|
||||
role: dto.role ?? TeamMemberRole.MEMBER,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(workspaceId: string, teamId: string, userId: string): Promise<void> {
|
||||
await this.ensureTeamInWorkspace(workspaceId, teamId);
|
||||
|
||||
const result = await this.prisma.teamMember.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
throw new NotFoundException(`User ${userId} is not a member of team ${teamId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(workspaceId: string, teamId: string): Promise<void> {
|
||||
const result = await this.prisma.team.deleteMany({
|
||||
where: {
|
||||
id: teamId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
throw new NotFoundException(`Team with ID ${teamId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureTeamInWorkspace(workspaceId: string, teamId: string): Promise<void> {
|
||||
const team = await this.prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
workspaceId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Team with ID ${teamId} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
112
apps/api/src/users/preferences.controller.spec.ts
Normal file
112
apps/api/src/users/preferences.controller.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { UnauthorizedException } from "@nestjs/common";
|
||||
import { PreferencesController } from "./preferences.controller";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import type { UpdatePreferencesDto, PreferencesResponseDto } from "./dto";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
|
||||
describe("PreferencesController", () => {
|
||||
let controller: PreferencesController;
|
||||
let service: PreferencesService;
|
||||
|
||||
const mockPreferencesService = {
|
||||
getPreferences: vi.fn(),
|
||||
updatePreferences: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUserId = "user-uuid-123";
|
||||
|
||||
const mockPreferencesResponse: PreferencesResponseDto = {
|
||||
id: "pref-uuid-456",
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
function makeRequest(userId?: string): AuthenticatedRequest {
|
||||
return {
|
||||
user: userId ? { id: userId } : undefined,
|
||||
} as unknown as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
service = mockPreferencesService as unknown as PreferencesService;
|
||||
controller = new PreferencesController(service);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/users/me/preferences", () => {
|
||||
it("should return preferences for authenticated user", async () => {
|
||||
mockPreferencesService.getPreferences.mockResolvedValue(mockPreferencesResponse);
|
||||
|
||||
const result = await controller.getPreferences(makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(mockPreferencesResponse);
|
||||
expect(mockPreferencesService.getPreferences).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.getPreferences(makeRequest())).rejects.toThrow(UnauthorizedException);
|
||||
expect(mockPreferencesService.getPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/users/me/preferences", () => {
|
||||
const updateDto: UpdatePreferencesDto = {
|
||||
theme: "dark",
|
||||
locale: "fr",
|
||||
timezone: "Europe/Paris",
|
||||
};
|
||||
|
||||
it("should update and return preferences for authenticated user", async () => {
|
||||
const updatedResponse: PreferencesResponseDto = {
|
||||
...mockPreferencesResponse,
|
||||
theme: "dark",
|
||||
locale: "fr",
|
||||
timezone: "Europe/Paris",
|
||||
};
|
||||
mockPreferencesService.updatePreferences.mockResolvedValue(updatedResponse);
|
||||
|
||||
const result = await controller.updatePreferences(updateDto, makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(updatedResponse);
|
||||
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, updateDto);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.updatePreferences(updateDto, makeRequest())).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/users/me/preferences", () => {
|
||||
const patchDto: UpdatePreferencesDto = {
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
it("should partially update and return preferences for authenticated user", async () => {
|
||||
const patchedResponse: PreferencesResponseDto = {
|
||||
...mockPreferencesResponse,
|
||||
theme: "light",
|
||||
};
|
||||
mockPreferencesService.updatePreferences.mockResolvedValue(patchedResponse);
|
||||
|
||||
const result = await controller.patchPreferences(patchDto, makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(patchedResponse);
|
||||
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, patchDto);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.patchPreferences(patchDto, makeRequest())).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Patch,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
@@ -38,7 +39,7 @@ export class PreferencesController {
|
||||
|
||||
/**
|
||||
* PUT /api/users/me/preferences
|
||||
* Update current user's preferences
|
||||
* Full replace of current user's preferences
|
||||
*/
|
||||
@Put()
|
||||
async updatePreferences(
|
||||
@@ -53,4 +54,22 @@ export class PreferencesController {
|
||||
|
||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/users/me/preferences
|
||||
* Partial update of current user's preferences
|
||||
*/
|
||||
@Patch()
|
||||
async patchPreferences(
|
||||
@Body() updatePreferencesDto: UpdatePreferencesDto,
|
||||
@Request() req: AuthenticatedRequest
|
||||
) {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||
}
|
||||
}
|
||||
|
||||
141
apps/api/src/users/preferences.service.spec.ts
Normal file
141
apps/api/src/users/preferences.service.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import type { PrismaService } from "../prisma/prisma.service";
|
||||
import type { UpdatePreferencesDto } from "./dto";
|
||||
|
||||
describe("PreferencesService", () => {
|
||||
let service: PreferencesService;
|
||||
|
||||
const mockPrisma = {
|
||||
userPreference: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockUserId = "user-uuid-123";
|
||||
|
||||
const mockDbPreference = {
|
||||
id: "pref-uuid-456",
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PreferencesService(mockPrisma as unknown as PrismaService);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getPreferences", () => {
|
||||
it("should return existing preferences", async () => {
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
|
||||
const result = await service.getPreferences(mockUserId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: mockDbPreference.id,
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
});
|
||||
expect(mockPrisma.userPreference.findUnique).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create default preferences when none exist", async () => {
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.userPreference.create.mockResolvedValue(mockDbPreference);
|
||||
|
||||
const result = await service.getPreferences(mockUserId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: mockDbPreference.id,
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePreferences", () => {
|
||||
it("should update existing preferences", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { theme: "dark", locale: "fr" };
|
||||
const updatedPreference = { ...mockDbPreference, theme: "dark", locale: "fr" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result).toMatchObject({ theme: "dark", locale: "fr" });
|
||||
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
data: expect.objectContaining({ theme: "dark", locale: "fr" }),
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create preferences when updating non-existent record", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { theme: "light" };
|
||||
const createdPreference = { ...mockDbPreference, theme: "light" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.userPreference.create.mockResolvedValue(createdPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result).toMatchObject({ theme: "light" });
|
||||
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: mockUserId,
|
||||
theme: "light",
|
||||
}),
|
||||
});
|
||||
expect(mockPrisma.userPreference.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle timezone update", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { timezone: "America/New_York" };
|
||||
const updatedPreference = { ...mockDbPreference, timezone: "America/New_York" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result.timezone).toBe("America/New_York");
|
||||
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
data: expect.objectContaining({ timezone: "America/New_York" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle settings update", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { settings: { notifications: true } };
|
||||
const updatedPreference = { ...mockDbPreference, settings: { notifications: true } };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result.settings).toEqual({ notifications: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { getTrustedOrigins } from "../auth/auth.config";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
@@ -77,7 +78,7 @@ interface StepOutputData {
|
||||
*/
|
||||
@WSGateway({
|
||||
cors: {
|
||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||
origin: getTrustedOrigins(),
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
@@ -167,17 +168,36 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Extract authentication token from Socket.IO handshake
|
||||
* @description Extract authentication token from Socket.IO handshake.
|
||||
*
|
||||
* Checks sources in order:
|
||||
* 1. handshake.auth.token — explicit token (e.g. from API clients)
|
||||
* 2. handshake.headers.cookie — session cookie sent by browser via withCredentials
|
||||
* 3. query.token — URL query parameter fallback
|
||||
* 4. Authorization header — Bearer token fallback
|
||||
*
|
||||
* @param client - The socket client
|
||||
* @returns The token string or undefined if not found
|
||||
*/
|
||||
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||
// Check handshake.auth.token (preferred method)
|
||||
// Check handshake.auth.token (preferred method for non-browser clients)
|
||||
const authToken = client.handshake.auth.token as unknown;
|
||||
if (typeof authToken === "string" && authToken.length > 0) {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
// Fallback: parse session cookie from request headers.
|
||||
// Browsers send httpOnly cookies automatically when withCredentials: true is set
|
||||
// on the socket.io client. BetterAuth uses one of these cookie names depending
|
||||
// on whether the connection is HTTPS (Secure prefix) or HTTP (dev).
|
||||
const cookieHeader = client.handshake.headers.cookie;
|
||||
if (typeof cookieHeader === "string" && cookieHeader.length > 0) {
|
||||
const cookieToken = this.extractTokenFromCookieHeader(cookieHeader);
|
||||
if (cookieToken) {
|
||||
return cookieToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check query parameters
|
||||
const queryToken = client.handshake.query.token as unknown;
|
||||
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||
@@ -197,6 +217,45 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Parse the BetterAuth session token from a raw Cookie header string.
|
||||
*
|
||||
* BetterAuth names the session cookie differently based on the security context:
|
||||
* - `__Secure-better-auth.session_token` — HTTPS with Secure flag
|
||||
* - `better-auth.session_token` — HTTP (development)
|
||||
* - `__Host-better-auth.session_token` — HTTPS with Host prefix
|
||||
*
|
||||
* @param cookieHeader - The raw Cookie header value
|
||||
* @returns The session token value or undefined if no matching cookie found
|
||||
*/
|
||||
private extractTokenFromCookieHeader(cookieHeader: string): string | undefined {
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
"__Secure-better-auth.session_token",
|
||||
"better-auth.session_token",
|
||||
"__Host-better-auth.session_token",
|
||||
] as const;
|
||||
|
||||
// Parse the Cookie header into a key-value map
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split(";").map((pair) => {
|
||||
const eqIndex = pair.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
return [pair.trim(), ""];
|
||||
}
|
||||
return [pair.slice(0, eqIndex).trim(), pair.slice(eqIndex + 1).trim()];
|
||||
})
|
||||
);
|
||||
|
||||
for (const name of SESSION_COOKIE_NAMES) {
|
||||
const value = cookies[name];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Handle client disconnect by leaving the workspace room.
|
||||
* @param client - The socket client containing workspaceId in data.
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
|
||||
import { WidgetsService } from "./widgets.service";
|
||||
import { WidgetDataService } from "./widget-data.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { RequestWithWorkspace } from "../common/types/user.types";
|
||||
|
||||
/**
|
||||
* Controller for widget definition and data endpoints
|
||||
* All endpoints require authentication
|
||||
* All endpoints require authentication; data endpoints also require workspace context
|
||||
*/
|
||||
@Controller("widgets")
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -51,12 +43,9 @@ export class WidgetsController {
|
||||
* Get stat card widget data
|
||||
*/
|
||||
@Post("data/stat-card")
|
||||
async getStatCardData(@Request() req: AuthenticatedRequest, @Body() query: StatCardQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getStatCardData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
|
||||
return this.widgetDataService.getStatCardData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,12 +53,9 @@ export class WidgetsController {
|
||||
* Get chart widget data
|
||||
*/
|
||||
@Post("data/chart")
|
||||
async getChartData(@Request() req: AuthenticatedRequest, @Body() query: ChartQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getChartData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
|
||||
return this.widgetDataService.getChartData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,12 +63,9 @@ export class WidgetsController {
|
||||
* Get list widget data
|
||||
*/
|
||||
@Post("data/list")
|
||||
async getListData(@Request() req: AuthenticatedRequest, @Body() query: ListQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getListData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
|
||||
return this.widgetDataService.getListData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,15 +73,12 @@ export class WidgetsController {
|
||||
* Get calendar preview widget data
|
||||
*/
|
||||
@Post("data/calendar-preview")
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getCalendarPreviewData(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Request() req: RequestWithWorkspace,
|
||||
@Body() query: CalendarPreviewQueryDto
|
||||
) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getCalendarPreviewData(workspaceId, query);
|
||||
return this.widgetDataService.getCalendarPreviewData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,12 +86,9 @@ export class WidgetsController {
|
||||
* Get active projects widget data
|
||||
*/
|
||||
@Post("data/active-projects")
|
||||
async getActiveProjectsData(@Request() req: AuthenticatedRequest) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getActiveProjectsData(workspaceId);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
|
||||
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,11 +96,8 @@ export class WidgetsController {
|
||||
* Get agent chains widget data (active agent sessions)
|
||||
*/
|
||||
@Post("data/agent-chains")
|
||||
async getAgentChainsData(@Request() req: AuthenticatedRequest) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getAgentChainsData(workspaceId);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
|
||||
return this.widgetDataService.getAgentChainsData(req.workspace.id);
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/api/src/workspaces/dto/add-member.dto.ts
Normal file
13
apps/api/src/workspaces/dto/add-member.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import { IsEnum, IsUUID } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for adding a user to a workspace.
|
||||
*/
|
||||
export class AddMemberDto {
|
||||
@IsUUID("4", { message: "userId must be a valid UUID" })
|
||||
userId!: string;
|
||||
|
||||
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||
role!: WorkspaceMemberRole;
|
||||
}
|
||||
3
apps/api/src/workspaces/dto/index.ts
Normal file
3
apps/api/src/workspaces/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { AddMemberDto } from "./add-member.dto";
|
||||
export { UpdateMemberRoleDto } from "./update-member-role.dto";
|
||||
export { WorkspaceResponseDto } from "./workspace-response.dto";
|
||||
10
apps/api/src/workspaces/dto/update-member-role.dto.ts
Normal file
10
apps/api/src/workspaces/dto/update-member-role.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import { IsEnum } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for updating a workspace member's role.
|
||||
*/
|
||||
export class UpdateMemberRoleDto {
|
||||
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||
role!: WorkspaceMemberRole;
|
||||
}
|
||||
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";
|
||||
149
apps/api/src/workspaces/workspaces.controller.spec.ts
Normal file
149
apps/api/src/workspaces/workspaces.controller.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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 { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import type { AuthUser } from "@mosaic/shared";
|
||||
|
||||
describe("WorkspacesController", () => {
|
||||
let controller: WorkspacesController;
|
||||
let service: WorkspacesService;
|
||||
|
||||
const mockWorkspacesService = {
|
||||
getUserWorkspaces: vi.fn(),
|
||||
addMember: vi.fn(),
|
||||
updateMemberRole: vi.fn(),
|
||||
removeMember: 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 })
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(PermissionGuard)
|
||||
.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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workspaces/:id/members", () => {
|
||||
it("should call service with workspace id, actor id, and add member dto", async () => {
|
||||
const workspaceId = "ws-1";
|
||||
const addMemberDto = {
|
||||
userId: "user-2",
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
};
|
||||
const mockMember = {
|
||||
workspaceId,
|
||||
userId: "user-2",
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
joinedAt: new Date("2026-02-01"),
|
||||
};
|
||||
mockWorkspacesService.addMember.mockResolvedValueOnce(mockMember);
|
||||
|
||||
const result = await controller.addMember(workspaceId, addMemberDto, mockUser);
|
||||
|
||||
expect(result).toEqual(mockMember);
|
||||
expect(service.addMember).toHaveBeenCalledWith(workspaceId, mockUser.id, addMemberDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/workspaces/:id/members/:userId", () => {
|
||||
it("should call service with workspace id, actor id, target user id, and role dto", async () => {
|
||||
const workspaceId = "ws-1";
|
||||
const targetUserId = "user-2";
|
||||
const updateRoleDto = {
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
};
|
||||
const mockMember = {
|
||||
workspaceId,
|
||||
userId: targetUserId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
joinedAt: new Date("2026-02-01"),
|
||||
};
|
||||
mockWorkspacesService.updateMemberRole.mockResolvedValueOnce(mockMember);
|
||||
|
||||
const result = await controller.updateMemberRole(
|
||||
workspaceId,
|
||||
targetUserId,
|
||||
updateRoleDto,
|
||||
mockUser
|
||||
);
|
||||
|
||||
expect(result).toEqual(mockMember);
|
||||
expect(service.updateMemberRole).toHaveBeenCalledWith(
|
||||
workspaceId,
|
||||
mockUser.id,
|
||||
targetUserId,
|
||||
updateRoleDto
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/workspaces/:id/members/:userId", () => {
|
||||
it("should call service with workspace id, actor id, and target user id", async () => {
|
||||
const workspaceId = "ws-1";
|
||||
const targetUserId = "user-2";
|
||||
mockWorkspacesService.removeMember.mockResolvedValueOnce(undefined);
|
||||
|
||||
await controller.removeMember(workspaceId, targetUserId, mockUser);
|
||||
|
||||
expect(service.removeMember).toHaveBeenCalledWith(workspaceId, mockUser.id, targetUserId);
|
||||
});
|
||||
});
|
||||
});
|
||||
85
apps/api/src/workspaces/workspaces.controller.ts
Normal file
85
apps/api/src/workspaces/workspaces.controller.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, 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 { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Permission, RequirePermission } from "../common/decorators";
|
||||
import type { WorkspaceMember } from "@prisma/client";
|
||||
import type { AuthenticatedUser } from "../common/types/user.types";
|
||||
import type { AddMemberDto, UpdateMemberRoleDto, 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: AuthenticatedUser): Promise<WorkspaceResponseDto[]> {
|
||||
return this.workspacesService.getUserWorkspaces(user.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/workspaces/:workspaceId/members
|
||||
* Add a member to a workspace with the specified role.
|
||||
* Requires: ADMIN role or higher.
|
||||
*/
|
||||
@Post(":workspaceId/members")
|
||||
@UseGuards(WorkspaceGuard, PermissionGuard)
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async addMember(
|
||||
@Param("workspaceId") workspaceId: string,
|
||||
@Body() addMemberDto: AddMemberDto,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
): Promise<WorkspaceMember> {
|
||||
return this.workspacesService.addMember(workspaceId, user.id, addMemberDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/workspaces/:workspaceId/members/:userId
|
||||
* Change a member role in a workspace.
|
||||
* Requires: ADMIN role or higher.
|
||||
*/
|
||||
@Patch(":workspaceId/members/:userId")
|
||||
@UseGuards(WorkspaceGuard, PermissionGuard)
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async updateMemberRole(
|
||||
@Param("workspaceId") workspaceId: string,
|
||||
@Param("userId") targetUserId: string,
|
||||
@Body() updateMemberRoleDto: UpdateMemberRoleDto,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
): Promise<WorkspaceMember> {
|
||||
return this.workspacesService.updateMemberRole(
|
||||
workspaceId,
|
||||
user.id,
|
||||
targetUserId,
|
||||
updateMemberRoleDto
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/workspaces/:workspaceId/members/:userId
|
||||
* Remove a member from a workspace.
|
||||
* Requires: ADMIN role or higher.
|
||||
*/
|
||||
@Delete(":workspaceId/members/:userId")
|
||||
@UseGuards(WorkspaceGuard, PermissionGuard)
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async removeMember(
|
||||
@Param("workspaceId") workspaceId: string,
|
||||
@Param("userId") targetUserId: string,
|
||||
@CurrentUser() user: AuthenticatedUser
|
||||
): Promise<void> {
|
||||
await this.workspacesService.removeMember(workspaceId, user.id, targetUserId);
|
||||
}
|
||||
}
|
||||
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 {}
|
||||
516
apps/api/src/workspaces/workspaces.service.spec.ts
Normal file
516
apps/api/src/workspaces/workspaces.service.spec.ts
Normal file
@@ -0,0 +1,516 @@
|
||||
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";
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
describe("WorkspacesService", () => {
|
||||
let service: WorkspacesService;
|
||||
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockAdminUserId = "550e8400-e29b-41d4-a716-446655440010";
|
||||
const mockMemberUserId = "550e8400-e29b-41d4-a716-446655440011";
|
||||
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(),
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
user: {
|
||||
findUnique: 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();
|
||||
|
||||
mockPrismaService.$transaction.mockImplementation(
|
||||
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) =>
|
||||
fn(mockPrismaService as unknown as typeof mockPrismaService)
|
||||
);
|
||||
});
|
||||
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMember", () => {
|
||||
const addMemberDto = {
|
||||
userId: mockMemberUserId,
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
};
|
||||
|
||||
it("should add a new member to the workspace", async () => {
|
||||
const createdMembership = {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockMemberUserId,
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
joinedAt: new Date("2026-02-02"),
|
||||
};
|
||||
mockPrismaService.user.findUnique.mockResolvedValueOnce({ id: mockMemberUserId });
|
||||
mockPrismaService.workspaceMember.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockAdminUserId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
})
|
||||
.mockResolvedValueOnce(null);
|
||||
mockPrismaService.workspaceMember.create.mockResolvedValueOnce(createdMembership);
|
||||
|
||||
const result = await service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto);
|
||||
|
||||
expect(result).toEqual(createdMembership);
|
||||
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockMemberUserId,
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when user does not exist", async () => {
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockAdminUserId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
});
|
||||
mockPrismaService.user.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw ConflictException when user is already a member", async () => {
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockAdminUserId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
});
|
||||
mockPrismaService.user.findUnique.mockResolvedValueOnce({ id: mockMemberUserId });
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockMemberUserId,
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
joinedAt: new Date("2026-01-02"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto)
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when admin tries to assign OWNER role", async () => {
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockAdminUserId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.addMember(mockWorkspaceId, mockAdminUserId, {
|
||||
userId: mockMemberUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
})
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateMemberRole", () => {
|
||||
it("should update a member role", async () => {
|
||||
const updatedMembership = {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockMemberUserId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
joinedAt: new Date("2026-01-02"),
|
||||
};
|
||||
mockPrismaService.workspaceMember.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockMemberUserId,
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
joinedAt: new Date("2026-01-02"),
|
||||
});
|
||||
mockPrismaService.workspaceMember.update.mockResolvedValueOnce(updatedMembership);
|
||||
|
||||
const result = await service.updateMemberRole(mockWorkspaceId, mockUserId, mockMemberUserId, {
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
});
|
||||
|
||||
expect(result).toEqual(updatedMembership);
|
||||
expect(mockPrismaService.workspaceMember.update).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockMemberUserId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when target member does not exist", async () => {
|
||||
mockPrismaService.workspaceMember.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
})
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(
|
||||
service.updateMemberRole(mockWorkspaceId, mockUserId, mockMemberUserId, {
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
})
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when sole owner attempts self-demotion", async () => {
|
||||
mockPrismaService.workspaceMember.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
});
|
||||
mockPrismaService.workspaceMember.count.mockResolvedValueOnce(1);
|
||||
|
||||
await expect(
|
||||
service.updateMemberRole(mockWorkspaceId, mockUserId, mockUserId, {
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
})
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when actor tries to change role of higher-ranked member", async () => {
|
||||
mockPrismaService.workspaceMember.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockAdminUserId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.updateMemberRole(mockWorkspaceId, mockAdminUserId, mockUserId, {
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
})
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeMember", () => {
|
||||
it("should remove a workspace member", async () => {
|
||||
mockPrismaService.workspaceMember.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockMemberUserId,
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
joinedAt: new Date("2026-01-02"),
|
||||
});
|
||||
mockPrismaService.workspaceMember.delete.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockMemberUserId,
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
joinedAt: new Date("2026-01-02"),
|
||||
});
|
||||
|
||||
await service.removeMember(mockWorkspaceId, mockUserId, mockMemberUserId);
|
||||
|
||||
expect(mockPrismaService.workspaceMember.delete).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockMemberUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw BadRequestException when trying to remove the last owner", async () => {
|
||||
mockPrismaService.workspaceMember.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
});
|
||||
mockPrismaService.workspaceMember.count.mockResolvedValueOnce(1);
|
||||
|
||||
await expect(service.removeMember(mockWorkspaceId, mockUserId, mockUserId)).rejects.toThrow(
|
||||
BadRequestException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ForbiddenException when admin attempts to remove an owner", async () => {
|
||||
mockPrismaService.workspaceMember.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockAdminUserId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.removeMember(mockWorkspaceId, mockAdminUserId, mockUserId)
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
});
|
||||
345
apps/api/src/workspaces/workspaces.service.ts
Normal file
345
apps/api/src/workspaces/workspaces.service.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Prisma, WorkspaceMemberRole } from "@prisma/client";
|
||||
import type { WorkspaceMember } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
|
||||
|
||||
const WORKSPACE_ROLE_RANK: Record<WorkspaceMemberRole, number> = {
|
||||
[WorkspaceMemberRole.GUEST]: 1,
|
||||
[WorkspaceMemberRole.MEMBER]: 2,
|
||||
[WorkspaceMemberRole.ADMIN]: 3,
|
||||
[WorkspaceMemberRole.OWNER]: 4,
|
||||
};
|
||||
|
||||
@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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a workspace.
|
||||
*/
|
||||
async addMember(
|
||||
workspaceId: string,
|
||||
actorUserId: string,
|
||||
addMemberDto: AddMemberDto
|
||||
): Promise<WorkspaceMember> {
|
||||
const actorMembership = await this.prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: actorUserId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!actorMembership) {
|
||||
throw new ForbiddenException("You are not a member of this workspace");
|
||||
}
|
||||
|
||||
this.assertCanAssignRole(actorMembership.role, addMemberDto.role);
|
||||
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { id: addMemberDto.userId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException(`User with ID ${addMemberDto.userId} not found`);
|
||||
}
|
||||
|
||||
const existingMembership = await this.prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: addMemberDto.userId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
workspaceId: true,
|
||||
userId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new ConflictException("User is already a member of this workspace");
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
userId: addMemberDto.userId,
|
||||
role: addMemberDto.role,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (this.isUniqueConstraintError(error)) {
|
||||
throw new ConflictException("User is already a member of this workspace");
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the role of an existing workspace member.
|
||||
*/
|
||||
async updateMemberRole(
|
||||
workspaceId: string,
|
||||
actorUserId: string,
|
||||
targetUserId: string,
|
||||
updateMemberRoleDto: UpdateMemberRoleDto
|
||||
): Promise<WorkspaceMember> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const actorMembership = await tx.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: actorUserId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!actorMembership) {
|
||||
throw new ForbiddenException("You are not a member of this workspace");
|
||||
}
|
||||
|
||||
const targetMembership = await tx.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: targetUserId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetMembership) {
|
||||
throw new NotFoundException(`User ${targetUserId} is not a member of this workspace`);
|
||||
}
|
||||
|
||||
this.assertCanManageTargetMember(actorMembership.role, targetMembership.role);
|
||||
this.assertCanAssignRole(actorMembership.role, updateMemberRoleDto.role);
|
||||
|
||||
if (targetMembership.role === WorkspaceMemberRole.OWNER) {
|
||||
const isDemotion = updateMemberRoleDto.role !== WorkspaceMemberRole.OWNER;
|
||||
if (isDemotion) {
|
||||
const ownerCount = await tx.workspaceMember.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
},
|
||||
});
|
||||
if (ownerCount <= 1) {
|
||||
if (actorUserId === targetUserId) {
|
||||
throw new BadRequestException("Cannot self-demote if you are the sole owner");
|
||||
}
|
||||
throw new BadRequestException("Cannot remove the last owner from a workspace");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.workspaceMember.update({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: targetUserId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
role: updateMemberRoleDto.role,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a workspace.
|
||||
*/
|
||||
async removeMember(
|
||||
workspaceId: string,
|
||||
actorUserId: string,
|
||||
targetUserId: string
|
||||
): Promise<void> {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const actorMembership = await tx.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: actorUserId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!actorMembership) {
|
||||
throw new ForbiddenException("You are not a member of this workspace");
|
||||
}
|
||||
|
||||
const targetMembership = await tx.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: targetUserId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!targetMembership) {
|
||||
throw new NotFoundException(`User ${targetUserId} is not a member of this workspace`);
|
||||
}
|
||||
|
||||
this.assertCanManageTargetMember(actorMembership.role, targetMembership.role);
|
||||
|
||||
if (targetMembership.role === WorkspaceMemberRole.OWNER) {
|
||||
const ownerCount = await tx.workspaceMember.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
},
|
||||
});
|
||||
if (ownerCount <= 1) {
|
||||
throw new BadRequestException("Cannot remove the last owner from a workspace");
|
||||
}
|
||||
}
|
||||
|
||||
await tx.workspaceMember.delete({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: targetUserId,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private assertCanAssignRole(
|
||||
actorRole: WorkspaceMemberRole,
|
||||
requestedRole: WorkspaceMemberRole
|
||||
): void {
|
||||
if (WORKSPACE_ROLE_RANK[actorRole] < WORKSPACE_ROLE_RANK[requestedRole]) {
|
||||
throw new ForbiddenException("You cannot assign a role higher than your own");
|
||||
}
|
||||
}
|
||||
|
||||
private assertCanManageTargetMember(
|
||||
actorRole: WorkspaceMemberRole,
|
||||
targetRole: WorkspaceMemberRole
|
||||
): void {
|
||||
if (WORKSPACE_ROLE_RANK[actorRole] < WORKSPACE_ROLE_RANK[targetRole]) {
|
||||
throw new ForbiddenException("You cannot manage a member with a higher role");
|
||||
}
|
||||
}
|
||||
|
||||
private isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
|
||||
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
|
||||
}
|
||||
}
|
||||
@@ -326,7 +326,7 @@ function LoginPageContent(): ReactElement {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<AuthStatusPill label="Mosaic v0.1" tone="neutral" />
|
||||
<AuthStatusPill label="Mosaic v0.0.20" tone="neutral" />
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function ProfilePage(): ReactElement {
|
||||
setPrefsError(null);
|
||||
|
||||
try {
|
||||
const data = await apiGet<UserPreferences>("/users/me/preferences");
|
||||
const data = await apiGet<UserPreferences>("/api/users/me/preferences");
|
||||
setPreferences(data);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Could not load preferences";
|
||||
@@ -265,23 +265,8 @@ export default function ProfilePage(): ReactElement {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{user?.workspaceRole && (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-block",
|
||||
marginTop: 8,
|
||||
padding: "3px 10px",
|
||||
borderRadius: "var(--r)",
|
||||
background: "rgba(47, 128, 255, 0.1)",
|
||||
color: "var(--ms-blue-400)",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "capitalize",
|
||||
}}
|
||||
>
|
||||
{user.workspaceRole}
|
||||
</span>
|
||||
)}
|
||||
{/* Workspace role badge — placeholder until workspace context API
|
||||
provides role data via GET /api/workspaces */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function AppearanceSettingsPage(): ReactElement {
|
||||
setLocalTheme(themeId);
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiPatch("/users/me/preferences", { theme: themeId });
|
||||
await apiPatch("/api/users/me/preferences", { theme: themeId });
|
||||
} catch {
|
||||
// Theme is still applied locally even if API save fails
|
||||
} finally {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
const ACTIVITY_ACTIONS = [
|
||||
{ value: "CREDENTIAL_CREATED", label: "Created" },
|
||||
@@ -39,17 +40,17 @@ export default function CredentialAuditPage(): React.ReactElement {
|
||||
const [filters, setFilters] = useState<FilterState>({});
|
||||
const [hasFilters, setHasFilters] = useState(false);
|
||||
|
||||
// TODO: Get workspace ID from context/auth
|
||||
const workspaceId = "default-workspace-id"; // Placeholder
|
||||
const workspaceId = useWorkspaceId();
|
||||
|
||||
useEffect(() => {
|
||||
void loadLogs();
|
||||
}, [page, filters]);
|
||||
if (!workspaceId) return;
|
||||
void loadLogs(workspaceId);
|
||||
}, [workspaceId, page, filters]);
|
||||
|
||||
async function loadLogs(): Promise<void> {
|
||||
async function loadLogs(wsId: string): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchCredentialAuditLog(workspaceId, {
|
||||
const response = await fetchCredentialAuditLog(wsId, {
|
||||
...filters,
|
||||
page,
|
||||
limit,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, type SyntheticEvent } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import type { Domain } from "@mosaic/shared";
|
||||
import { DomainList } from "@/components/domains/DomainList";
|
||||
import { fetchDomains, deleteDomain } from "@/lib/api/domains";
|
||||
import { fetchDomains, createDomain, deleteDomain } from "@/lib/api/domains";
|
||||
import type { CreateDomainDto } from "@/lib/api/domains";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
export default function DomainsPage(): React.ReactElement {
|
||||
/* ---------------------------------------------------------------------------
|
||||
Slug generation helper
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Create Domain Dialog
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface CreateDomainDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: CreateDomainDto) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function CreateDomainDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: CreateDomainDialogProps): ReactElement | null {
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
function resetForm(): void {
|
||||
setName("");
|
||||
setSlug("");
|
||||
setSlugTouched(false);
|
||||
setDescription("");
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
function handleNameChange(value: string): void {
|
||||
setName(value);
|
||||
if (!slugTouched) {
|
||||
setSlug(generateSlug(value));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlugChange(value: string): void {
|
||||
setSlugTouched(true);
|
||||
setSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setFormError("Domain name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedSlug = slug.trim();
|
||||
if (!trimmedSlug) {
|
||||
setFormError("Slug is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(trimmedSlug)) {
|
||||
setFormError("Slug must contain only lowercase letters, numbers, and hyphens.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: CreateDomainDto = { name: trimmedName, slug: trimmedSlug };
|
||||
const trimmedDesc = description.trim();
|
||||
if (trimmedDesc) {
|
||||
payload.description = trimmedDesc;
|
||||
}
|
||||
await onSubmit(payload);
|
||||
resetForm();
|
||||
} catch (err: unknown) {
|
||||
setFormError(err instanceof Error ? err.message : "Failed to create domain.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-domain-title"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isSubmitting) {
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
background: "var(--surface, #fff)",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--border, #e5e7eb)",
|
||||
padding: 24,
|
||||
width: "100%",
|
||||
maxWidth: 480,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="create-domain-title"
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text, #111)",
|
||||
margin: "0 0 8px",
|
||||
}}
|
||||
>
|
||||
New Domain
|
||||
</h2>
|
||||
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
|
||||
Domains help you organize tasks, projects, and events by life area.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(e);
|
||||
}}
|
||||
>
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="domain-name"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2, #374151)",
|
||||
}}
|
||||
>
|
||||
Name <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="domain-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
handleNameChange(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. Personal Finance"
|
||||
maxLength={255}
|
||||
autoFocus
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg, #f9fafb)",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text, #111)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="domain-slug"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2, #374151)",
|
||||
}}
|
||||
>
|
||||
Slug <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="domain-slug"
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
handleSlugChange(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. personal-finance"
|
||||
maxLength={100}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg, #f9fafb)",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text, #111)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
fontFamily: "var(--mono, monospace)",
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--muted, #6b7280)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
Lowercase letters, numbers, and hyphens only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="domain-description"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2, #374151)",
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="domain-description"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
placeholder="A brief summary of this domain..."
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg, #f9fafb)",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text, #111)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form error */}
|
||||
{formError !== null && (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--danger, #ef4444)",
|
||||
fontSize: "0.85rem",
|
||||
margin: "0 0 12px",
|
||||
}}
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-2, #374151)",
|
||||
fontSize: "0.85rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim() || !slug.trim()}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--primary, #111827)",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: isSubmitting || !name.trim() || !slug.trim() ? "not-allowed" : "pointer",
|
||||
opacity: isSubmitting || !name.trim() || !slug.trim() ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Domain"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Domains Page
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
export default function DomainsPage(): ReactElement {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadDomains();
|
||||
}, []);
|
||||
}, [workspaceId]);
|
||||
|
||||
async function loadDomains(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchDomains();
|
||||
const response = await fetchDomains(undefined, workspaceId ?? undefined);
|
||||
setDomains(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -27,9 +387,8 @@ export default function DomainsPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(domain: Domain): void {
|
||||
function handleEdit(_domain: Domain): void {
|
||||
// TODO: Open edit modal/form
|
||||
console.log("Edit domain:", domain);
|
||||
}
|
||||
|
||||
async function handleDelete(domain: Domain): Promise<void> {
|
||||
@@ -38,13 +397,26 @@ export default function DomainsPage(): React.ReactElement {
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteDomain(domain.id);
|
||||
await deleteDomain(domain.id, workspaceId ?? undefined);
|
||||
await loadDomains();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete domain");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(data: CreateDomainDto): Promise<void> {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createDomain(data, workspaceId ?? undefined);
|
||||
setCreateOpen(false);
|
||||
await loadDomains();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create domain.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
@@ -60,7 +432,7 @@ export default function DomainsPage(): React.ReactElement {
|
||||
<button
|
||||
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
|
||||
onClick={() => {
|
||||
console.log("TODO: Open create modal");
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Domain
|
||||
@@ -73,6 +445,13 @@ export default function DomainsPage(): React.ReactElement {
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<CreateDomainDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={handleCreate}
|
||||
isSubmitting={isCreating}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,6 +39,12 @@ const mockMembers: WorkspaceMemberWithUser[] = [
|
||||
image: null,
|
||||
authProviderId: null,
|
||||
preferences: {},
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
invitedBy: null,
|
||||
invitationToken: null,
|
||||
invitedAt: null,
|
||||
createdAt: new Date("2024-01-15"),
|
||||
updatedAt: new Date("2024-01-15"),
|
||||
},
|
||||
@@ -56,6 +62,12 @@ const mockMembers: WorkspaceMemberWithUser[] = [
|
||||
image: null,
|
||||
authProviderId: null,
|
||||
preferences: {},
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
invitedBy: null,
|
||||
invitationToken: null,
|
||||
invitedAt: null,
|
||||
createdAt: new Date("2024-01-16"),
|
||||
updatedAt: new Date("2024-01-16"),
|
||||
},
|
||||
@@ -73,6 +85,12 @@ const mockMembers: WorkspaceMemberWithUser[] = [
|
||||
image: null,
|
||||
authProviderId: null,
|
||||
preferences: {},
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
invitedBy: null,
|
||||
invitationToken: null,
|
||||
invitedAt: null,
|
||||
createdAt: new Date("2024-01-17"),
|
||||
updatedAt: new Date("2024-01-17"),
|
||||
},
|
||||
|
||||
63
apps/web/src/app/(authenticated)/terminal/page.tsx
Normal file
63
apps/web/src/app/(authenticated)/terminal/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Terminal page — dedicated full-screen terminal route at /terminal.
|
||||
*
|
||||
* Renders the TerminalPanel component filling the available content area.
|
||||
* The panel is always open on this page; there is no close action since
|
||||
* the user navigates away using the sidebar instead.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { TerminalPanel } from "@/components/terminal";
|
||||
import { getAccessToken } from "@/lib/auth-client";
|
||||
|
||||
export default function TerminalPage(): ReactElement {
|
||||
const [token, setToken] = useState<string>("");
|
||||
|
||||
// Resolve the access token once on mount. The WebSocket connection inside
|
||||
// TerminalPanel uses this token for authentication.
|
||||
useEffect((): void => {
|
||||
getAccessToken()
|
||||
.then((t) => {
|
||||
setToken(t ?? "");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("[TerminalPage] Failed to retrieve access token:", err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Override TerminalPanel inline height so it fills the page */}
|
||||
<style>{`
|
||||
.terminal-page-panel {
|
||||
height: 100% !important;
|
||||
border-top: none !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
aria-label="Terminal"
|
||||
>
|
||||
<TerminalPanel
|
||||
open={true}
|
||||
onClose={(): void => {
|
||||
/* No-op: on the dedicated terminal page the panel is always open.
|
||||
Users navigate away using the sidebar rather than closing the panel. */
|
||||
}}
|
||||
token={token}
|
||||
className="terminal-page-panel"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 539 B |
@@ -11,6 +11,9 @@ export const dynamic = "force-dynamic";
|
||||
export const metadata: Metadata = {
|
||||
title: "Mosaic Stack",
|
||||
description: "Mosaic Stack Web Application",
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
const outfit = Outfit({
|
||||
|
||||
@@ -21,6 +21,12 @@ const mockAvailableUsers: User[] = [
|
||||
image: null,
|
||||
authProviderId: null,
|
||||
preferences: {},
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
invitedBy: null,
|
||||
invitationToken: null,
|
||||
invitedAt: null,
|
||||
createdAt: new Date("2026-01-17"),
|
||||
updatedAt: new Date("2026-01-17"),
|
||||
},
|
||||
@@ -32,6 +38,12 @@ const mockAvailableUsers: User[] = [
|
||||
image: null,
|
||||
authProviderId: null,
|
||||
preferences: {},
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
invitedBy: null,
|
||||
invitationToken: null,
|
||||
invitedAt: null,
|
||||
createdAt: new Date("2026-01-18"),
|
||||
updatedAt: new Date("2026-01-18"),
|
||||
},
|
||||
|
||||
@@ -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,7 +90,11 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||
});
|
||||
|
||||
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
||||
// 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 = useWorkspaceId() ?? "";
|
||||
const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {});
|
||||
|
||||
const { isCommand, executeCommand } = useOrchestratorCommands();
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
badge: { label: "live", pulse: true },
|
||||
},
|
||||
{
|
||||
href: "#terminal",
|
||||
href: "/terminal",
|
||||
label: "Terminal",
|
||||
icon: <IconTerminal />,
|
||||
},
|
||||
@@ -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 (
|
||||
<footer
|
||||
|
||||
@@ -91,7 +91,7 @@ export function SelectTrigger({
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className={`flex h-10 w-full items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ${className}`}
|
||||
className={`flex h-10 w-full items-center justify-between rounded-md border border-border bg-bg px-3 py-2 text-sm text-text ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -110,7 +110,7 @@ export function SelectContent({ children }: SelectContentProps): React.JSX.Eleme
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-surface shadow-md">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -122,7 +122,7 @@ export function SelectItem({ value, children }: SelectItemProps): React.JSX.Elem
|
||||
return (
|
||||
<div
|
||||
onClick={() => onValueChange?.(value)}
|
||||
className="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
|
||||
className="cursor-pointer px-3 py-2 text-sm text-text hover:bg-surface-2"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState, useEffect } from "react";
|
||||
import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
interface ActiveProject {
|
||||
id: string;
|
||||
@@ -34,6 +35,7 @@ interface AgentSession {
|
||||
}
|
||||
|
||||
export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const [projects, setProjects] = useState<ActiveProject[]>([]);
|
||||
const [agentSessions, setAgentSessions] = useState<AgentSession[]>([]);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||
@@ -48,7 +50,11 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
try {
|
||||
setProjectsError(null);
|
||||
// Use API client to ensure CSRF token is included
|
||||
const data = await apiPost<ActiveProject[]>("/api/widgets/data/active-projects");
|
||||
const data = await apiPost<ActiveProject[]>(
|
||||
"/api/widgets/data/active-projects",
|
||||
undefined,
|
||||
workspaceId ?? undefined
|
||||
);
|
||||
setProjects(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch active projects:", error);
|
||||
@@ -67,7 +73,7 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [workspaceId]);
|
||||
|
||||
// Fetch agent chains
|
||||
useEffect(() => {
|
||||
@@ -75,7 +81,11 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
try {
|
||||
setAgentsError(null);
|
||||
// Use API client to ensure CSRF token is included
|
||||
const data = await apiPost<AgentSession[]>("/api/widgets/data/agent-chains");
|
||||
const data = await apiPost<AgentSession[]>(
|
||||
"/api/widgets/data/agent-chains",
|
||||
undefined,
|
||||
workspaceId ?? undefined
|
||||
);
|
||||
setAgentSessions(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agent sessions:", error);
|
||||
@@ -94,7 +104,7 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [workspaceId]);
|
||||
|
||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||
const statusUpper = status.toUpperCase();
|
||||
|
||||
@@ -20,6 +20,12 @@ const makeMember = (
|
||||
image: null,
|
||||
authProviderId: `auth-${overrides.userId}`,
|
||||
preferences: {},
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
invitedBy: null,
|
||||
invitationToken: null,
|
||||
invitedAt: null,
|
||||
createdAt: new Date("2025-01-01"),
|
||||
updatedAt: new Date("2025-01-01"),
|
||||
},
|
||||
@@ -62,6 +68,12 @@ describe("MemberList", (): void => {
|
||||
image: null,
|
||||
authProviderId: "auth-2",
|
||||
preferences: {},
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
invitedBy: null,
|
||||
invitationToken: null,
|
||||
invitedAt: null,
|
||||
createdAt: new Date("2025-01-01"),
|
||||
updatedAt: new Date("2025-01-01"),
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ describe("useWebSocket", (): void => {
|
||||
expect(io).toHaveBeenCalledWith(expect.any(String), {
|
||||
auth: { token },
|
||||
query: { workspaceId },
|
||||
withCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -97,9 +97,12 @@ export function useWebSocket(
|
||||
setConnectionError(null);
|
||||
|
||||
// Create socket connection
|
||||
// withCredentials sends session cookies cross-origin so the gateway can
|
||||
// authenticate via cookie when no explicit token is provided.
|
||||
const newSocket = io(wsUrl, {
|
||||
auth: { token },
|
||||
query: { workspaceId },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
|
||||
@@ -202,9 +202,13 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
||||
...baseHeaders,
|
||||
};
|
||||
|
||||
// Add workspace ID header if provided (recommended over query string)
|
||||
if (workspaceId) {
|
||||
headers["X-Workspace-Id"] = workspaceId;
|
||||
// Add workspace ID header — use explicit value, or auto-detect from localStorage
|
||||
const resolvedWorkspaceId =
|
||||
workspaceId ??
|
||||
(typeof window !== "undefined" ? localStorage.getItem("mosaic-workspace-id") : null) ??
|
||||
undefined;
|
||||
if (resolvedWorkspaceId) {
|
||||
headers["X-Workspace-Id"] = resolvedWorkspaceId;
|
||||
}
|
||||
|
||||
// Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)
|
||||
@@ -246,6 +250,11 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// 204 No Content responses have no body — return undefined cast to T
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await (response.json() as Promise<T>);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
|
||||
@@ -44,7 +44,10 @@ export interface DomainFilters {
|
||||
/**
|
||||
* Fetch all domains
|
||||
*/
|
||||
export async function fetchDomains(filters?: DomainFilters): Promise<ApiResponse<Domain[]>> {
|
||||
export async function fetchDomains(
|
||||
filters?: DomainFilters,
|
||||
workspaceId?: string
|
||||
): Promise<ApiResponse<Domain[]>> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.search) {
|
||||
@@ -60,7 +63,7 @@ export async function fetchDomains(filters?: DomainFilters): Promise<ApiResponse
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/domains?${queryString}` : "/api/domains";
|
||||
|
||||
return apiGet<ApiResponse<Domain[]>>(endpoint);
|
||||
return apiGet<ApiResponse<Domain[]>>(endpoint, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,20 +76,27 @@ export async function fetchDomain(id: string): Promise<DomainWithCounts> {
|
||||
/**
|
||||
* Create a new domain
|
||||
*/
|
||||
export async function createDomain(data: CreateDomainDto): Promise<Domain> {
|
||||
return apiPost<Domain>("/api/domains", data);
|
||||
export async function createDomain(data: CreateDomainDto, workspaceId?: string): Promise<Domain> {
|
||||
return apiPost<Domain>("/api/domains", data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a domain
|
||||
*/
|
||||
export async function updateDomain(id: string, data: UpdateDomainDto): Promise<Domain> {
|
||||
return apiPatch<Domain>(`/api/domains/${id}`, data);
|
||||
export async function updateDomain(
|
||||
id: string,
|
||||
data: UpdateDomainDto,
|
||||
workspaceId?: string
|
||||
): Promise<Domain> {
|
||||
return apiPatch<Domain>(`/api/domains/${id}`, data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a domain
|
||||
*/
|
||||
export async function deleteDomain(id: string): Promise<Record<string, never>> {
|
||||
return apiDelete<Record<string, never>>(`/api/domains/${id}`);
|
||||
export async function deleteDomain(
|
||||
id: string,
|
||||
workspaceId?: string
|
||||
): Promise<Record<string, never>> {
|
||||
return apiDelete<Record<string, never>>(`/api/domains/${id}`, workspaceId);
|
||||
}
|
||||
|
||||
@@ -15,3 +15,4 @@ export * from "./personalities";
|
||||
export * from "./telemetry";
|
||||
export * from "./dashboard";
|
||||
export * from "./projects";
|
||||
export * from "./workspaces";
|
||||
|
||||
@@ -73,7 +73,8 @@ export async function updatePersonality(
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
* The DELETE endpoint returns 204 No Content on success.
|
||||
*/
|
||||
export async function deletePersonality(id: string): Promise<Record<string, never>> {
|
||||
return apiDelete<Record<string, never>>(`/api/personalities/${id}`);
|
||||
export async function deletePersonality(id: string): Promise<void> {
|
||||
await apiDelete<undefined>(`/api/personalities/${id}`);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@ export interface UpdateProjectDto {
|
||||
* Fetch all projects for a workspace
|
||||
*/
|
||||
export async function fetchProjects(workspaceId?: string): Promise<Project[]> {
|
||||
return apiGet<Project[]>("/api/projects", workspaceId);
|
||||
const response = await apiGet<{ data: Project[]; meta?: unknown }>("/api/projects", workspaceId);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
/**
|
||||
* Teams API Client
|
||||
* Handles team-related API requests
|
||||
*/
|
||||
|
||||
import type { Team, TeamMember, User } from "@mosaic/shared";
|
||||
import type {
|
||||
Team,
|
||||
TeamMember,
|
||||
User,
|
||||
WorkspaceMemberRole,
|
||||
} from "@mosaic/shared";
|
||||
import { TeamMemberRole } from "@mosaic/shared";
|
||||
import { apiGet, apiPost, apiPatch, apiDelete, type ApiResponse } from "./client";
|
||||
import { apiDelete, apiGet, apiPost, type ApiResponse } from "./client";
|
||||
|
||||
export interface TeamMemberWithUser extends TeamMember {
|
||||
user: Pick<User, "id" | "name" | "email" | "image">;
|
||||
}
|
||||
|
||||
export interface TeamWithMembers extends Team {
|
||||
members: (TeamMember & { user: User })[];
|
||||
members?: TeamMemberWithUser[];
|
||||
_count?: {
|
||||
members: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceMemberWithUser {
|
||||
workspaceId: string;
|
||||
userId: string;
|
||||
role: WorkspaceMemberRole;
|
||||
joinedAt: string | Date;
|
||||
user: Pick<User, "id" | "name" | "email" | "image">;
|
||||
}
|
||||
|
||||
export interface CreateTeamDto {
|
||||
@@ -16,108 +31,81 @@ export interface CreateTeamDto {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTeamDto {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface AddTeamMemberDto {
|
||||
userId: string;
|
||||
role?: TeamMemberRole;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all teams for a workspace
|
||||
*/
|
||||
export async function fetchTeams(workspaceId: string): Promise<Team[]> {
|
||||
const response = await apiGet<ApiResponse<Team[]>>(`/api/workspaces/${workspaceId}/teams`);
|
||||
return response.data;
|
||||
type ApiPayload<T> = T | ApiResponse<T>;
|
||||
|
||||
function isApiResponse<T>(payload: ApiPayload<T>): payload is ApiResponse<T> {
|
||||
return typeof payload === "object" && payload !== null && "data" in payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single team with members
|
||||
*/
|
||||
export async function fetchTeam(workspaceId: string, teamId: string): Promise<TeamWithMembers> {
|
||||
const response = await apiGet<ApiResponse<TeamWithMembers>>(
|
||||
`/api/workspaces/${workspaceId}/teams/${teamId}`
|
||||
function unwrapPayload<T>(payload: ApiPayload<T>): T {
|
||||
return isApiResponse(payload) ? payload.data : payload;
|
||||
}
|
||||
|
||||
export function getTeamMemberCount(team: TeamWithMembers): number {
|
||||
if (Array.isArray(team.members)) {
|
||||
return team.members.length;
|
||||
}
|
||||
|
||||
return team._count?.members ?? 0;
|
||||
}
|
||||
|
||||
export async function fetchTeams(workspaceId: string): Promise<TeamWithMembers[]> {
|
||||
const payload = await apiGet<ApiPayload<TeamWithMembers[]>>(
|
||||
`/api/workspaces/${workspaceId}/teams`,
|
||||
workspaceId
|
||||
);
|
||||
return response.data;
|
||||
return unwrapPayload(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new team
|
||||
*/
|
||||
export async function createTeam(workspaceId: string, data: CreateTeamDto): Promise<Team> {
|
||||
const response = await apiPost<ApiResponse<Team>>(`/api/workspaces/${workspaceId}/teams`, data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a team
|
||||
*/
|
||||
export async function updateTeam(
|
||||
workspaceId: string,
|
||||
teamId: string,
|
||||
data: UpdateTeamDto
|
||||
): Promise<Team> {
|
||||
const response = await apiPatch<ApiResponse<Team>>(
|
||||
`/api/workspaces/${workspaceId}/teams/${teamId}`,
|
||||
data
|
||||
export async function createTeam(workspaceId: string, data: CreateTeamDto): Promise<TeamWithMembers> {
|
||||
const payload = await apiPost<ApiPayload<TeamWithMembers>>(
|
||||
`/api/workspaces/${workspaceId}/teams`,
|
||||
data,
|
||||
workspaceId
|
||||
);
|
||||
return response.data;
|
||||
return unwrapPayload(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a team
|
||||
*/
|
||||
export async function deleteTeam(workspaceId: string, teamId: string): Promise<void> {
|
||||
await apiDelete(`/api/workspaces/${workspaceId}/teams/${teamId}`);
|
||||
await apiDelete<void>(`/api/workspaces/${workspaceId}/teams/${teamId}`, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a team
|
||||
*/
|
||||
export async function addTeamMember(
|
||||
workspaceId: string,
|
||||
teamId: string,
|
||||
data: AddTeamMemberDto
|
||||
): Promise<TeamMember> {
|
||||
const response = await apiPost<ApiResponse<TeamMember>>(
|
||||
): Promise<TeamMemberWithUser> {
|
||||
const payload = await apiPost<ApiPayload<TeamMemberWithUser>>(
|
||||
`/api/workspaces/${workspaceId}/teams/${teamId}/members`,
|
||||
data
|
||||
data,
|
||||
workspaceId
|
||||
);
|
||||
return response.data;
|
||||
return unwrapPayload(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a team
|
||||
*/
|
||||
export async function removeTeamMember(
|
||||
workspaceId: string,
|
||||
teamId: string,
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
await apiDelete(`/api/workspaces/${workspaceId}/teams/${teamId}/members/${userId}`);
|
||||
await apiDelete<void>(`/api/workspaces/${workspaceId}/teams/${teamId}/members/${userId}`, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a team member's role
|
||||
*/
|
||||
export async function updateTeamMemberRole(
|
||||
workspaceId: string,
|
||||
teamId: string,
|
||||
userId: string,
|
||||
role: TeamMemberRole
|
||||
): Promise<TeamMember> {
|
||||
const response = await apiPatch<ApiResponse<TeamMember>>(
|
||||
`/api/workspaces/${workspaceId}/teams/${teamId}/members/${userId}`,
|
||||
{ role }
|
||||
export async function fetchWorkspaceMembers(workspaceId: string): Promise<WorkspaceMemberWithUser[]> {
|
||||
const payload = await apiGet<ApiPayload<WorkspaceMemberWithUser[]>>(
|
||||
`/api/workspaces/${workspaceId}/members`,
|
||||
workspaceId
|
||||
);
|
||||
return response.data;
|
||||
return unwrapPayload(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mock teams for development (until backend endpoints are ready)
|
||||
* Mock teams for development in legacy routes under /app/settings.
|
||||
*/
|
||||
export const mockTeams: Team[] = [
|
||||
{
|
||||
@@ -133,7 +121,7 @@ export const mockTeams: Team[] = [
|
||||
id: "team-2",
|
||||
workspaceId: "workspace-1",
|
||||
name: "Design",
|
||||
description: "UI/UX design team",
|
||||
description: "UI and UX design team",
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-01-22"),
|
||||
updatedAt: new Date("2026-01-22"),
|
||||
@@ -149,24 +137,16 @@ export const mockTeams: Team[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Mock team with members for development
|
||||
*/
|
||||
const baseTeam = mockTeams[0];
|
||||
if (!baseTeam) {
|
||||
throw new Error("Mock team not found");
|
||||
const [defaultMockTeam] = mockTeams;
|
||||
if (!defaultMockTeam) {
|
||||
throw new Error("Mock team was not found");
|
||||
}
|
||||
|
||||
export const mockTeamWithMembers: TeamWithMembers = {
|
||||
id: baseTeam.id,
|
||||
workspaceId: baseTeam.workspaceId,
|
||||
name: baseTeam.name,
|
||||
description: baseTeam.description,
|
||||
metadata: baseTeam.metadata,
|
||||
createdAt: baseTeam.createdAt,
|
||||
updatedAt: baseTeam.updatedAt,
|
||||
...defaultMockTeam,
|
||||
members: [
|
||||
{
|
||||
teamId: "team-1",
|
||||
teamId: defaultMockTeam.id,
|
||||
userId: "user-1",
|
||||
role: TeamMemberRole.OWNER,
|
||||
joinedAt: new Date("2026-01-20"),
|
||||
@@ -174,16 +154,11 @@ export const mockTeamWithMembers: TeamWithMembers = {
|
||||
id: "user-1",
|
||||
email: "john@example.com",
|
||||
name: "John Doe",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
authProviderId: null,
|
||||
preferences: {},
|
||||
createdAt: new Date("2026-01-15"),
|
||||
updatedAt: new Date("2026-01-15"),
|
||||
},
|
||||
},
|
||||
{
|
||||
teamId: "team-1",
|
||||
teamId: defaultMockTeam.id,
|
||||
userId: "user-2",
|
||||
role: TeamMemberRole.MEMBER,
|
||||
joinedAt: new Date("2026-01-21"),
|
||||
@@ -191,12 +166,7 @@ export const mockTeamWithMembers: TeamWithMembers = {
|
||||
id: "user-2",
|
||||
email: "jane@example.com",
|
||||
name: "Jane Smith",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
authProviderId: null,
|
||||
preferences: {},
|
||||
createdAt: new Date("2026-01-16"),
|
||||
updatedAt: new Date("2026-01-16"),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
26
apps/web/src/lib/api/workspaces.ts
Normal file
26
apps/web/src/lib/api/workspaces.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Workspaces API Client
|
||||
* User-scoped workspace discovery — does NOT require X-Workspace-Id header.
|
||||
*/
|
||||
|
||||
import { apiGet } from "./client";
|
||||
|
||||
/**
|
||||
* A workspace entry from the user's membership list.
|
||||
* Matches WorkspaceResponseDto from the API.
|
||||
*/
|
||||
export interface UserWorkspace {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
role: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all workspaces the authenticated user is a member of.
|
||||
* The API auto-provisions a default workspace if the user has none.
|
||||
*/
|
||||
export async function fetchUserWorkspaces(): Promise<UserWorkspace[]> {
|
||||
return apiGet<UserWorkspace[]>("/api/workspaces");
|
||||
}
|
||||
@@ -10,7 +10,13 @@ vi.mock("../api/client", () => ({
|
||||
apiPost: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock the workspaces API client
|
||||
vi.mock("../api/workspaces", () => ({
|
||||
fetchUserWorkspaces: vi.fn(),
|
||||
}));
|
||||
|
||||
const { apiGet, apiPost } = await import("../api/client");
|
||||
const { fetchUserWorkspaces } = await import("../api/workspaces");
|
||||
|
||||
/** Helper: returns a date far in the future (1 hour from now) for session mocks */
|
||||
function futureExpiry(): string {
|
||||
@@ -691,4 +697,225 @@ describe("AuthContext", (): void => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace ID persistence", (): void => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage mock for workspace persistence tests
|
||||
// ---------------------------------------------------------------------------
|
||||
interface MockLocalStorage {
|
||||
getItem: ReturnType<typeof vi.fn>;
|
||||
setItem: ReturnType<typeof vi.fn>;
|
||||
removeItem: ReturnType<typeof vi.fn>;
|
||||
clear: ReturnType<typeof vi.fn>;
|
||||
readonly length: number;
|
||||
key: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
let localStorageMock: MockLocalStorage;
|
||||
|
||||
beforeEach((): void => {
|
||||
let store: Record<string, string> = {};
|
||||
localStorageMock = {
|
||||
getItem: vi.fn((key: string): string | null => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string): void => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string): void => {
|
||||
store = Object.fromEntries(Object.entries(store).filter(([k]) => k !== key));
|
||||
}),
|
||||
clear: vi.fn((): void => {
|
||||
store = {};
|
||||
}),
|
||||
get length(): number {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: vi.fn((_index: number): string | null => null),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should call fetchUserWorkspaces after successful session check", async (): Promise<void> => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([
|
||||
{
|
||||
id: "ws-1",
|
||||
name: "My Workspace",
|
||||
ownerId: "user-1",
|
||||
role: "OWNER",
|
||||
createdAt: "2026-01-01",
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
expect(fetchUserWorkspaces).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should persist the first workspace ID to localStorage", async (): Promise<void> => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([
|
||||
{
|
||||
id: "ws-abc-123",
|
||||
name: "My Workspace",
|
||||
ownerId: "user-1",
|
||||
role: "OWNER",
|
||||
createdAt: "2026-01-01",
|
||||
},
|
||||
{
|
||||
id: "ws-def-456",
|
||||
name: "Second Workspace",
|
||||
ownerId: "other",
|
||||
role: "MEMBER",
|
||||
createdAt: "2026-02-01",
|
||||
},
|
||||
]);
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith("mosaic-workspace-id", "ws-abc-123");
|
||||
});
|
||||
|
||||
it("should not write localStorage when fetchUserWorkspaces returns empty array", async (): Promise<void> => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([]);
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).not.toHaveBeenCalledWith(
|
||||
"mosaic-workspace-id",
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle fetchUserWorkspaces failure gracefully — auth still succeeds", async (): Promise<void> => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
vi.mocked(fetchUserWorkspaces).mockRejectedValueOnce(new Error("Network error"));
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
// Auth succeeded despite workspace fetch failure
|
||||
expect(screen.getByTestId("auth-error")).toHaveTextContent("none");
|
||||
});
|
||||
|
||||
it("should remove workspace ID from localStorage on sign-out", async (): Promise<void> => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([
|
||||
{
|
||||
id: "ws-1",
|
||||
name: "My Workspace",
|
||||
ownerId: "user-1",
|
||||
role: "OWNER",
|
||||
createdAt: "2026-01-01",
|
||||
},
|
||||
]);
|
||||
vi.mocked(apiPost).mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
const signOutButton = screen.getByRole("button", { name: "Sign Out" });
|
||||
signOutButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated");
|
||||
});
|
||||
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith("mosaic-workspace-id");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "react";
|
||||
import type { AuthUser, AuthSession } from "@mosaic/shared";
|
||||
import { apiGet, apiPost } from "../api/client";
|
||||
import { fetchUserWorkspaces } from "../api/workspaces";
|
||||
import { IS_MOCK_AUTH_MODE } from "../config";
|
||||
import { parseAuthError } from "./auth-errors";
|
||||
|
||||
@@ -24,6 +25,43 @@ const SESSION_EXPIRY_WARNING_MINUTES = 5;
|
||||
|
||||
/** Interval in milliseconds to check session expiry */
|
||||
const SESSION_CHECK_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* localStorage key for the active workspace ID.
|
||||
* Must match the WORKSPACE_KEY constant in useLayout.ts and the key read
|
||||
* by apiRequest in client.ts.
|
||||
*/
|
||||
const WORKSPACE_STORAGE_KEY = "mosaic-workspace-id";
|
||||
|
||||
/**
|
||||
* Persist the workspace ID to localStorage so it is available to
|
||||
* useWorkspaceId and apiRequest on the next render / request cycle.
|
||||
* Silently ignores localStorage errors (private browsing, storage full).
|
||||
*/
|
||||
function persistWorkspaceId(workspaceId: string | undefined): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (workspaceId) {
|
||||
localStorage.setItem(WORKSPACE_STORAGE_KEY, workspaceId);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable — not fatal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the workspace ID from localStorage on sign-out so stale workspace
|
||||
* context is not sent on subsequent unauthenticated requests.
|
||||
*/
|
||||
function clearWorkspaceId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.removeItem(WORKSPACE_STORAGE_KEY);
|
||||
} catch {
|
||||
// localStorage unavailable — not fatal
|
||||
}
|
||||
}
|
||||
|
||||
const MOCK_AUTH_USER: AuthUser = {
|
||||
id: "dev-user-local",
|
||||
email: "dev@localhost",
|
||||
@@ -97,6 +135,19 @@ function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Elem
|
||||
setUser(session.user);
|
||||
setAuthError(null);
|
||||
|
||||
// Fetch the user's workspace memberships and persist the default.
|
||||
// Workspace context is an application concern, not an auth concern —
|
||||
// BetterAuth does not return workspace fields on the session user.
|
||||
try {
|
||||
const workspaces = await fetchUserWorkspaces();
|
||||
const defaultWorkspace = workspaces[0];
|
||||
if (defaultWorkspace) {
|
||||
persistWorkspaceId(defaultWorkspace.id);
|
||||
}
|
||||
} catch (wsError) {
|
||||
logAuthError("Failed to fetch workspaces after session check", wsError);
|
||||
}
|
||||
|
||||
// Track session expiry timestamp
|
||||
expiresAtRef.current = new Date(session.session.expiresAt);
|
||||
|
||||
@@ -128,6 +179,9 @@ function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Elem
|
||||
setUser(null);
|
||||
expiresAtRef.current = null;
|
||||
setSessionExpiring(false);
|
||||
// Clear persisted workspace ID so stale context is not sent on
|
||||
// subsequent unauthenticated API requests.
|
||||
clearWorkspaceId();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -158,6 +158,8 @@ services:
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
- NEXT_PUBLIC_ORCHESTRATOR_URL=${NEXT_PUBLIC_ORCHESTRATOR_URL:-}
|
||||
- NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE:-real}
|
||||
# Server-side orchestrator proxy (API routes forward to orchestrator service over internal network)
|
||||
- ORCHESTRATOR_URL=http://orchestrator:3001
|
||||
- ORCHESTRATOR_API_KEY=${ORCHESTRATOR_API_KEY:-}
|
||||
depends_on:
|
||||
api:
|
||||
@@ -222,6 +224,8 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- ORCHESTRATOR_PORT=3001
|
||||
# Bind to all interfaces so the web container can reach it over Docker networking
|
||||
- HOST=0.0.0.0
|
||||
- AI_PROVIDER=${AI_PROVIDER:-ollama}
|
||||
- OLLAMA_ENDPOINT=${OLLAMA_ENDPOINT:-}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.2}
|
||||
|
||||
@@ -176,6 +176,9 @@ services:
|
||||
NODE_ENV: production
|
||||
PORT: ${WEB_PORT:-3000}
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
|
||||
# Server-side orchestrator proxy (API routes forward to orchestrator service)
|
||||
ORCHESTRATOR_URL: http://orchestrator:3001
|
||||
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -187,6 +190,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- internal
|
||||
- traefik-public
|
||||
deploy:
|
||||
restart_policy:
|
||||
@@ -248,6 +252,8 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
ORCHESTRATOR_PORT: 3001
|
||||
# Bind to all interfaces so the web container can reach it over Docker networking
|
||||
HOST: 0.0.0.0
|
||||
AI_PROVIDER: ${AI_PROVIDER:-ollama}
|
||||
VALKEY_URL: redis://valkey:6379
|
||||
VALKEY_HOST: valkey
|
||||
@@ -259,6 +265,8 @@ services:
|
||||
GIT_USER_EMAIL: "orchestrator@mosaicstack.dev"
|
||||
KILLSWITCH_ENABLED: "true"
|
||||
SANDBOX_ENABLED: "true"
|
||||
# API key for authenticating requests from the web proxy
|
||||
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- orchestrator_workspace:/workspace
|
||||
|
||||
@@ -433,6 +433,8 @@ services:
|
||||
NODE_ENV: production
|
||||
# Orchestrator Configuration
|
||||
ORCHESTRATOR_PORT: 3001
|
||||
# Bind to all interfaces so the web container can reach it over Docker networking
|
||||
HOST: 0.0.0.0
|
||||
AI_PROVIDER: ${AI_PROVIDER:-ollama}
|
||||
# Valkey
|
||||
VALKEY_URL: redis://valkey:6379
|
||||
@@ -448,6 +450,8 @@ services:
|
||||
# Security
|
||||
KILLSWITCH_ENABLED: true
|
||||
SANDBOX_ENABLED: true
|
||||
# API key for authenticating requests from the web proxy
|
||||
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
|
||||
ports:
|
||||
- "3002:3001"
|
||||
volumes:
|
||||
@@ -498,6 +502,8 @@ services:
|
||||
NODE_ENV: production
|
||||
PORT: ${WEB_PORT:-3000}
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
|
||||
# Server-side orchestrator proxy (API routes forward to orchestrator service)
|
||||
ORCHESTRATOR_URL: http://orchestrator:3001
|
||||
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
|
||||
ports:
|
||||
- "${WEB_PORT:-3000}:${WEB_PORT:-3000}"
|
||||
@@ -515,6 +521,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- mosaic-internal
|
||||
- mosaic-public
|
||||
labels:
|
||||
- "com.mosaic.service=web"
|
||||
|
||||
@@ -1,88 +1,52 @@
|
||||
# Mission Manifest — MS19 Chat & Terminal System
|
||||
# Mission Manifest — MS21 Multi-Tenant RBAC Data Migration
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** ms19-chat-terminal-20260225
|
||||
**Statement:** Implement MS19 (Chat & Terminal System) — real terminal with PTY backend, chat streaming, master chat polish, project-level orchestrator chat, and agent output integration
|
||||
**Phase:** Completion
|
||||
**Current Milestone:** MS19-ChatTerminal
|
||||
**Progress:** 1 / 1 milestones
|
||||
**Status:** completed
|
||||
**Last Updated:** 2026-02-26T04:20Z
|
||||
**ID:** ms21-multi-tenant-rbac-data-migration-20260228
|
||||
**Statement:** Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack
|
||||
**Phase:** Intake
|
||||
**Current Milestone:** —
|
||||
**Progress:** 0 / 6 milestones
|
||||
**Status:** active
|
||||
**Last Updated:** 2026-02-28 17:10 UTC
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Terminal panel has real xterm.js with PTY backend via WebSocket — **DONE** (PR #518)
|
||||
2. Terminal supports multiple named sessions (create/close/rename tabs) — **DONE** (PR #520)
|
||||
3. Terminal sessions persist in PostgreSQL and recover on reconnect — **DONE** (PR #517)
|
||||
4. Chat streaming renders tokens in real-time via SSE — **DONE** (PR #516)
|
||||
5. Master chat sidebar accessible from any page (Cmd+Shift+J / Cmd+K) — **DONE** (PR #519)
|
||||
6. Master chat supports model selection, temperature, conversation management — **DONE** (PR #519)
|
||||
7. Project-level chat can trigger orchestrator actions (/spawn, /status, /jobs) — **DONE** (PR #521)
|
||||
8. Agent output from orchestrator viewable in terminal tabs — **DONE** (PR #522)
|
||||
9. All features support all 5 themes (Dark, Light, Nord, Dracula, Solarized) — **DONE** (CSS variables)
|
||||
10. Lint, typecheck, and tests pass — **DONE** (1441 web + 3303 API = 4744 tests)
|
||||
11. Deployed and smoke-tested at mosaic.woltje.com — **DONE** (CI #635 green, web image sha:7165e7a deployed)
|
||||
|
||||
## Existing Infrastructure
|
||||
|
||||
Key components already built that MS19 builds upon:
|
||||
|
||||
| Component | Status | Location |
|
||||
| --------------------------------- | ------------------- | ------------------------------------ |
|
||||
| ChatOverlay + ConversationSidebar | ~95% complete | `apps/web/src/components/chat/` |
|
||||
| LLM Controller with SSE | Working | `apps/api/src/llm/` |
|
||||
| WebSocket Gateway | Production | `apps/api/src/websocket/` |
|
||||
| TerminalPanel UI (mock) | UI-only, no backend | `apps/web/src/components/terminal/` |
|
||||
| Orchestrator proxy routes | Working | `apps/web/src/app/api/orchestrator/` |
|
||||
| Speech Gateway (pattern ref) | Production | `apps/api/src/speech/` |
|
||||
| Ideas API (chat persistence) | Working | `apps/api/src/ideas/` |
|
||||
<!-- Define measurable success criteria here -->
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ---- | ---------------------- | --------- | ------------------------- | ------------------------ | ---------- | ---------- |
|
||||
| 1 | MS19 | Chat & Terminal System | completed | per-task feature branches | #508,#509,#510,#511,#512 | 2026-02-25 | 2026-02-25 |
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ------- | -------------------------- | ------- | ------ | ----- | ------- | --------- |
|
||||
| 1 | phase-1 | Schema and Admin API | pending | — | — | — | — |
|
||||
| 2 | phase-2 | Break-Glass Authentication | pending | — | — | — | — |
|
||||
| 3 | phase-3 | Data Migration | pending | — | — | — | — |
|
||||
| 4 | phase-4 | Admin UI | pending | — | — | — | — |
|
||||
| 5 | phase-5 | RBAC UI Enforcement | pending | — | — | — | — |
|
||||
| 6 | phase-6 | Verification | pending | — | — | — | — |
|
||||
|
||||
## Deployment
|
||||
|
||||
| Target | URL | Method |
|
||||
| --------- | ----------------- | --------------------------- |
|
||||
| Portainer | mosaic.woltje.com | CI/CD pipeline (Woodpecker) |
|
||||
| Target | URL | Method |
|
||||
| ------ | --- | ------ |
|
||||
| — | — | — |
|
||||
|
||||
## Token Budget
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ----------------- |
|
||||
| Budget | ~300K (estimated) |
|
||||
| Used | ~220K |
|
||||
| Mode | normal |
|
||||
| Metric | Value |
|
||||
| ------ | ------ |
|
||||
| Budget | — |
|
||||
| Used | 0 |
|
||||
| Mode | normal |
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ----------------- | -------- | ------------ | ------------------------------------------------- |
|
||||
| S1 | Claude Opus 4.6 | 2026-02-25T20:00Z | ~1h | context | Planning (PLAN-001) |
|
||||
| S2 | Claude Opus 4.6 | 2026-02-25T21:00Z | ~2h | context | Wave 1+2 (5 tasks, PRs #515-518) |
|
||||
| S3 | Claude Opus 4.6 | 2026-02-25T23:00Z | ~1.5h | context | Wave 3+4 (TERM-004, CHAT-002, ORCH-001, ORCH-002) |
|
||||
| S4 | Claude Opus 4.6 | 2026-02-26T04:00Z | ~30m | completed | VER-001, DOC-001, VER-002 — mission complete |
|
||||
|
||||
## PRs Merged
|
||||
|
||||
| PR | Commit | Task | Description |
|
||||
| ---- | ------- | -------- | ---------------------------------------- |
|
||||
| #515 | 6290fc3 | TERM-001 | Terminal WebSocket gateway & PTY service |
|
||||
| #516 | 7de0e73 | CHAT-001 | SSE chat streaming |
|
||||
| #517 | 8128eb7 | TERM-002 | Terminal session persistence |
|
||||
| #518 | 417c6ab | TERM-003 | xterm.js integration |
|
||||
| #519 | 13aa52a | CHAT-002 | Master chat polish |
|
||||
| #520 | 859dcfc | TERM-004 | Terminal tab management |
|
||||
| #521 | b110c46 | ORCH-001 | Orchestrator command system |
|
||||
| #522 | 9b2520c | ORCH-002 | Agent output terminal tabs |
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | ------- | ------- | -------- | ------------ | --------- |
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Path: `docs/scratchpads/ms19-chat-terminal-20260225.md`
|
||||
Path: `docs/scratchpads/ms21-multi-tenant-rbac-data-migration-20260228.md`
|
||||
|
||||
246
docs/PRD-MS21.md
Normal file
246
docs/PRD-MS21.md
Normal file
@@ -0,0 +1,246 @@
|
||||
# PRD: MS21 — Multi-Tenant Platform, RBAC Enforcement, and Data Migration
|
||||
|
||||
## Metadata
|
||||
|
||||
- Owner: Jason Woltje
|
||||
- Orchestrator: Jarvis (OpenClaw)
|
||||
- Date: 2026-02-28
|
||||
- Status: in-progress
|
||||
- Version: 0.0.21
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Mosaic Stack is a single-user deployment. The Workspace, Team, WorkspaceMember, and RBAC infrastructure exist in the schema and codebase (PermissionGuard, WorkspaceGuard, roles: OWNER/ADMIN/MEMBER/GUEST), but there is no admin UI for managing users, workspaces, or teams. There is no invitation flow, no user management page, and no break-glass authentication mechanism for emergency access without OIDC. Additionally, the platform has no real operational data — jarvis-brain contains 106 projects and 95 tasks that need migrating into Mosaic Stack to make the dashboard, kanban, and project pages useful.
|
||||
|
||||
## Objectives
|
||||
|
||||
1. Build admin UI for user management (list, invite, deactivate, assign roles)
|
||||
2. Build workspace management UI (create, configure, manage members)
|
||||
3. Build team management UI (create teams within workspaces, assign members)
|
||||
4. Implement user invitation flow (email or link-based)
|
||||
5. Implement break-glass local authentication (bypass OIDC for emergency access)
|
||||
6. Enforce RBAC across all UI surfaces (show/hide based on role)
|
||||
7. Build admin Settings pages for workspace and platform configuration
|
||||
8. Migrate jarvis-brain data (95 tasks, 106 projects) into Mosaic Stack PostgreSQL
|
||||
9. Build data import API endpoint for bulk task/project creation
|
||||
|
||||
## Completed Work
|
||||
|
||||
### Existing Infrastructure (Already Built)
|
||||
|
||||
| Component | Status | Location |
|
||||
| ----------------------------------------------------------------- | -------- | ------------------------------------------------------- |
|
||||
| Prisma models: User, Workspace, WorkspaceMember, Team, TeamMember | Complete | apps/api/prisma/schema.prisma |
|
||||
| WorkspaceMemberRole enum (OWNER, ADMIN, MEMBER, GUEST) | Complete | schema.prisma |
|
||||
| TeamMemberRole enum (OWNER, ADMIN, MEMBER) | Complete | schema.prisma |
|
||||
| PermissionGuard with role hierarchy | Complete | apps/api/src/common/guards/permission.guard.ts |
|
||||
| Permission decorator (@RequirePermission) | Complete | apps/api/src/common/decorators/permissions.decorator.ts |
|
||||
| Permission enum (WORKSPACE_OWNER, ADMIN, MEMBER, ANY) | Complete | permissions.decorator.ts |
|
||||
| WorkspaceGuard | Complete | apps/api/src/common/guards/workspace.guard.ts |
|
||||
| RBAC applied to 18+ controllers | Complete | All resource controllers |
|
||||
| Workspaces CRUD API | Complete | apps/api/src/workspaces/ |
|
||||
| BetterAuth + Authentik OIDC | Complete | apps/api/src/auth/ |
|
||||
| AdminGuard | Complete | apps/api/src/auth/guards/admin.guard.ts |
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope (MS21)
|
||||
|
||||
#### S1: Admin API Endpoints (Backend)
|
||||
|
||||
1. GET /api/admin/users — List all users with workspace memberships and roles
|
||||
2. POST /api/admin/users/invite — Generate invitation (email or link)
|
||||
3. PATCH /api/admin/users/:id — Update user metadata (deactivate, change global role)
|
||||
4. DELETE /api/admin/users/:id — Deactivate user (soft delete, preserve data)
|
||||
5. POST /api/admin/workspaces — Create workspace with owner assignment
|
||||
6. PATCH /api/admin/workspaces/:id — Update workspace settings
|
||||
7. POST /api/workspaces/:id/members — Add member to workspace with role
|
||||
8. PATCH /api/workspaces/:id/members/:userId — Change member role
|
||||
9. DELETE /api/workspaces/:id/members/:userId — Remove member from workspace
|
||||
10. POST /api/workspaces/:id/teams — Create team within workspace
|
||||
11. POST /api/workspaces/:id/teams/:teamId/members — Add member to team
|
||||
12. DELETE /api/workspaces/:id/teams/:teamId/members/:userId — Remove from team
|
||||
13. POST /api/import/tasks — Bulk import tasks from jarvis-brain format
|
||||
14. POST /api/import/projects — Bulk import projects from jarvis-brain format
|
||||
|
||||
#### S2: Break-Glass Authentication
|
||||
|
||||
15. Add isLocalAuth boolean field to User model (Prisma migration)
|
||||
16. Add passwordHash optional field to User model (for local auth users)
|
||||
17. Implement /api/auth/local/login endpoint (email + password, bcrypt)
|
||||
18. Implement /api/auth/local/setup endpoint (first-time break-glass user creation, requires admin token from env)
|
||||
19. Break-glass user bypasses OIDC entirely — session-based auth via BetterAuth
|
||||
20. Environment variable BREAKGLASS_SETUP_TOKEN controls initial setup access
|
||||
|
||||
#### S3: Admin UI Pages (Frontend)
|
||||
|
||||
21. /settings/users — User management page (table: name, email, role, status, workspaces, last login)
|
||||
22. User detail/edit dialog (change role, deactivate, view workspace memberships)
|
||||
23. Invite user dialog (email input, workspace selection, role selection)
|
||||
24. /settings/workspaces — Workspace management page (list workspaces, member counts)
|
||||
25. Workspace detail page (members list, team list, settings)
|
||||
26. Add/remove workspace member dialog with role picker
|
||||
27. /settings/teams — Team management within workspace context
|
||||
28. Create team dialog, add/remove team members
|
||||
|
||||
#### S4: RBAC UI Enforcement
|
||||
|
||||
29. Navigation sidebar: show/hide admin items based on user role
|
||||
30. Settings pages: restrict access to admin-only pages
|
||||
31. Action buttons: disable/hide create/delete based on permission level
|
||||
32. User profile: show current role and workspace memberships
|
||||
|
||||
#### S5: Data Migration
|
||||
|
||||
33. Build scripts/migrate-brain.ts — TypeScript migration script
|
||||
34. Read jarvis-brain data/tasks/\*.json (v2.0 format: { version, domain, tasks: [...] })
|
||||
35. Read jarvis-brain data/projects/\*.json (format: { version, project: {...}, tasks: [...] })
|
||||
36. Map brain status to Mosaic TaskStatus (done->COMPLETED, in-progress->IN_PROGRESS, backlog/pending/scheduled/not-started/planned->NOT_STARTED, blocked/on-hold->PAUSED, cancelled->ARCHIVED)
|
||||
37. Map brain priority to Mosaic TaskPriority (critical/high->HIGH, medium->MEDIUM, low->LOW)
|
||||
38. Create Domain records for unique domains (work, homelab, finances, family, etc.)
|
||||
39. Create Project records from project data, linking to domains
|
||||
40. Create Task records linking to projects, preserving brain-specific fields in metadata JSON
|
||||
41. Preserve blocks, blocked_by, repo, branch, current_milestone, notes in task/project metadata
|
||||
42. Dry-run mode with validation report before actual import
|
||||
43. Idempotent — skip records that already exist (match by metadata.brainId)
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Federation (MS22)
|
||||
- Agent task mapping and telemetry (MS23)
|
||||
- Playwright E2E tests (MS24)
|
||||
- Email delivery service (invitations stored as links for now)
|
||||
- User self-registration (admin-only invitation model)
|
||||
|
||||
## User/Stakeholder Requirements
|
||||
|
||||
1. Jason (admin) can invite Melanie to a shared workspace
|
||||
2. Jason can create a "USC IT" workspace and invite employees
|
||||
3. Each user sees only their workspace(s) data
|
||||
4. Break-glass user can log in without Authentik being available
|
||||
5. Admin pages are only visible to OWNER/ADMIN roles
|
||||
6. Data from jarvis-brain appears in the dashboard, kanban, and project pages after migration
|
||||
7. All existing tests continue to pass after changes
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
### FR-001: Admin User Management API
|
||||
|
||||
- AdminGuard protects all /api/admin/\* routes
|
||||
- List users returns: id, name, email, emailVerified, createdAt, workspace memberships with roles
|
||||
- Invite creates a User record with a pending invitation token
|
||||
- Deactivate sets a deactivatedAt timestamp (new field), does not delete data
|
||||
- ASSUMPTION: User deactivation is soft-delete via new deactivatedAt field on User model. Rationale: Hard delete would cascade and destroy workspace data.
|
||||
|
||||
### FR-002: Workspace Member Management API
|
||||
|
||||
- Add member requires WORKSPACE_ADMIN or WORKSPACE_OWNER permission
|
||||
- Role changes require equal or higher permission (MEMBER cannot promote to ADMIN)
|
||||
- Cannot remove the last OWNER of a workspace
|
||||
- Cannot change own role to lower than OWNER if sole owner
|
||||
|
||||
### FR-003: Break-Glass Authentication
|
||||
|
||||
- Local auth is opt-in per deployment via ENABLE_LOCAL_AUTH=true env var
|
||||
- Break-glass setup requires a one-time setup token from environment
|
||||
- Password stored as bcrypt hash, minimum 12 characters
|
||||
- Break-glass user gets OWNER role on default workspace
|
||||
- Session management identical to OIDC users (BetterAuth session tokens)
|
||||
- ASSUMPTION: BetterAuth supports custom credential providers alongside OIDC. Rationale: BetterAuth documentation confirms credential-based auth as a built-in feature.
|
||||
|
||||
### FR-004: Admin UI
|
||||
|
||||
- User management table with search, sort, filter by role/status
|
||||
- Inline role editing via dropdown
|
||||
- Confirmation dialog for destructive actions (deactivate user, remove from workspace)
|
||||
- PDA-friendly language: "Deactivate" not "Delete", "Pending" not "Expired"
|
||||
- Responsive design matching existing Settings page patterns
|
||||
- Dark/light theme support via existing design token system
|
||||
|
||||
### FR-005: Data Migration Script
|
||||
|
||||
- Standalone TypeScript script in scripts/migrate-brain.ts
|
||||
- Reads from jarvis-brain path (configurable via --brain-path flag)
|
||||
- Connects to Mosaic Stack database via DATABASE_URL
|
||||
- Requires target workspace ID (--workspace-id flag)
|
||||
- Requires creator user ID (--user-id flag)
|
||||
- Outputs validation report before writing (dry-run by default)
|
||||
- --apply flag to execute the migration
|
||||
- Creates Activity log entries for all imported records
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Schema Changes (Prisma Migration)
|
||||
|
||||
Add to User model:
|
||||
|
||||
- deactivatedAt DateTime? (soft delete)
|
||||
- isLocalAuth Boolean default(false)
|
||||
- passwordHash String? (bcrypt hash for local auth)
|
||||
- invitedBy String? Uuid (FK to inviting user)
|
||||
- invitationToken String? unique
|
||||
- invitedAt DateTime?
|
||||
|
||||
### New NestJS Module: AdminModule
|
||||
|
||||
Location: apps/api/src/admin/
|
||||
Files: admin.module.ts, admin.controller.ts, admin.service.ts, DTOs, specs
|
||||
|
||||
### New NestJS Module: LocalAuthModule (Break-Glass)
|
||||
|
||||
Location: apps/api/src/auth/local/
|
||||
Files: local-auth.controller.ts, local-auth.service.ts, DTOs, specs
|
||||
|
||||
### Frontend Pages
|
||||
|
||||
Location: apps/web/app/(authenticated)/settings/
|
||||
|
||||
- users/page.tsx — User management
|
||||
- workspaces/page.tsx — Workspace list
|
||||
- workspaces/[id]/page.tsx — Workspace detail (members, teams)
|
||||
- teams/page.tsx — Team management
|
||||
|
||||
## Testing and Verification
|
||||
|
||||
1. Baseline: pnpm lint && pnpm build && pnpm test must pass
|
||||
2. Unit tests for AdminService (user CRUD, invitation flow, permission checks)
|
||||
3. Unit tests for LocalAuthService (bcrypt hashing, token validation)
|
||||
4. Unit tests for AdminController (route guards, DTO validation)
|
||||
5. Integration test: invitation -> acceptance -> workspace access
|
||||
6. Integration test: break-glass setup -> login -> workspace access
|
||||
7. Integration test: RBAC prevents unauthorized role changes
|
||||
8. Migration script test: dry-run produces valid report, apply creates correct records
|
||||
9. Frontend: admin pages render with correct role gating
|
||||
10. All 4,772+ existing tests continue to pass
|
||||
|
||||
## Quality Gates
|
||||
|
||||
pnpm lint && pnpm build && pnpm test
|
||||
|
||||
## Delivery Phases
|
||||
|
||||
| Phase | Focus | Tasks |
|
||||
| ----------------------- | ----------------------------------------------------------------------- | ----------------- |
|
||||
| P1: Schema + Admin API | Prisma migration, AdminModule, user/workspace/team management endpoints | S1 items 1-12 |
|
||||
| P2: Break-Glass Auth | LocalAuthModule, credential provider, setup flow | S2 items 15-20 |
|
||||
| P3: Data Migration | Migration script, jarvis-brain to PostgreSQL | S5 items 33-43 |
|
||||
| P4: Admin UI | Settings pages for users, workspaces, teams | S3 items 21-28 |
|
||||
| P5: RBAC UI Enforcement | Frontend permission gating, navigation filtering | S4 items 29-32 |
|
||||
| P6: Verification | Full test pass, deployment, smoke test | All quality gates |
|
||||
|
||||
## Risks and Open Questions
|
||||
|
||||
1. Risk: BetterAuth credential provider may require specific adapter configuration. Mitigation: Review BetterAuth docs for credential auth alongside OIDC; test in isolation first.
|
||||
2. Risk: Schema migration on production database. Mitigation: Use Prisma migration with --create-only for review before applying.
|
||||
3. Risk: jarvis-brain data has fields that don't map 1:1 to Mosaic schema. Mitigation: Use metadata JSON field for overflow; validate with dry-run.
|
||||
4. Open: Should deactivated users retain active sessions? ASSUMPTION: No, deactivation immediately invalidates all sessions. Rationale: Security best practice.
|
||||
5. Open: Should break-glass user be auto-created on first deployment? ASSUMPTION: No, requires explicit setup via API with env token. Rationale: Prevents accidental exposure.
|
||||
|
||||
## Assumptions
|
||||
|
||||
1. User deactivation is soft-delete via deactivatedAt field. Hard delete cascades and destroys workspace data.
|
||||
2. BetterAuth supports custom credential providers alongside OIDC.
|
||||
3. Invitation flow uses link-based tokens (no email service required initially).
|
||||
4. Break-glass auth is off by default, controlled by ENABLE_LOCAL_AUTH env var.
|
||||
5. Migration script runs outside the API server as a standalone script.
|
||||
6. Admin pages follow existing Settings page UI patterns (cards, tables, dialogs).
|
||||
93
docs/PRD.md
93
docs/PRD.md
@@ -134,21 +134,28 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
||||
13. Global terminal: project/orchestrator level, smart (MS19)
|
||||
14. Project-level orchestrator chat (MS19)
|
||||
15. Master chat session: collapsible sidebar/slideout, always available (MS19)
|
||||
16. Settings page for ALL environment variables, dynamically configurable via webUI (MS20)
|
||||
17. Multi-tenant configuration with admin user management (MS20)
|
||||
18. Team management with shared data spaces and chat rooms (MS20)
|
||||
19. RBAC for file access, resources, models (MS20)
|
||||
20. Federation: master-master and master-slave with key exchange (MS21)
|
||||
21. Federation testing: 3 instances on Portainer (woltje.com domain) (MS21)
|
||||
22. Agent task mapping configuration: system-level defaults, user-level overrides (MS22)
|
||||
23. Telemetry: opt-out, customizable endpoint, sanitized data (MS22)
|
||||
24. File manager with WYSIWYG editing: system/user/project levels (MS18)
|
||||
25. User-level and project-level Kanban with filtering (MS18)
|
||||
26. Break-glass authentication user (MS20)
|
||||
27. Playwright E2E tests for all pages (MS23)
|
||||
28. API documentation via Swagger (MS23)
|
||||
29. Backend endpoints for all dashboard data (MS17 — already complete for existing modules)
|
||||
30. Profile page linked from user card (MS16)
|
||||
16. Site stabilization: workspace context propagation for mutations (MS20)
|
||||
17. Site stabilization: personalities API + UI (MS20)
|
||||
18. Site stabilization: user preferences API endpoint (MS20)
|
||||
19. Site stabilization: orchestrator 502 and WebSocket connectivity (MS20)
|
||||
20. Site stabilization: credential management UI (MS20)
|
||||
21. Site stabilization: terminal page route (MS20)
|
||||
22. Site stabilization: favicon, dark mode dropdown fix (MS20)
|
||||
23. Settings page for ALL environment variables, dynamically configurable via webUI (MS21)
|
||||
24. Multi-tenant configuration with admin user management (MS21)
|
||||
25. Team management with shared data spaces and chat rooms (MS21)
|
||||
26. RBAC for file access, resources, models (MS21)
|
||||
27. Federation: master-master and master-slave with key exchange (MS22)
|
||||
28. Federation testing: 3 instances on Portainer (woltje.com domain) (MS22)
|
||||
29. Agent task mapping configuration: system-level defaults, user-level overrides (MS23)
|
||||
30. Telemetry: opt-out, customizable endpoint, sanitized data (MS23)
|
||||
31. File manager with WYSIWYG editing: system/user/project levels (MS18)
|
||||
32. User-level and project-level Kanban with filtering (MS18)
|
||||
33. Break-glass authentication user (MS20)
|
||||
34. Playwright E2E tests for all pages (MS23)
|
||||
35. API documentation via Swagger (MS23)
|
||||
36. Backend endpoints for all dashboard data (MS17 — already complete for existing modules)
|
||||
37. Profile page linked from user card (MS16)
|
||||
|
||||
### Out of Scope
|
||||
|
||||
@@ -334,7 +341,46 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
||||
- ASSUMPTION: Orchestrator commands route through existing web proxy (/api/orchestrator/\*) to orchestrator service. Rationale: Proxy routes already exist and handle auth.
|
||||
- **Status: COMPLETE (MS19) — PRs #521 (commands), #522 (agent terminal). /status, /agents, /jobs, /pause, /resume, /help commands. Agent output streaming via SSE. 113 web tests.**
|
||||
|
||||
### FR-020: Settings Configuration (Future — MS20)
|
||||
### FR-020: Site Stabilization & Feature Gaps (MS20) — IN PROGRESS
|
||||
|
||||
Runtime bugs and feature gaps discovered during live testing of mosaic.woltje.com.
|
||||
|
||||
**Workspace Context Propagation:**
|
||||
|
||||
- Domains page: "Workspace ID is required" when creating domains
|
||||
- Projects page: "Workspace ID is required" when creating projects
|
||||
- Credentials page: unable to add credentials (button disabled, feature stub)
|
||||
- ASSUMPTION: The `useWorkspaceId()` hook + auto-detect in `apiRequest` from PR #532 handles reads, but mutation endpoints on some pages don't pass workspace ID correctly. Rationale: GET requests work after PR #532 but POST/mutation requests still fail on domains and projects pages.
|
||||
|
||||
**Missing API Endpoints:**
|
||||
|
||||
- `/api/personalities` — no controller/service exists; frontend expects GET/POST/PATCH/DELETE
|
||||
- `/users/me/preferences` — listed in PRD API table but returns 404; frontend profile page depends on it
|
||||
- ASSUMPTION: Personalities API follows existing NestJS module patterns (controller + service + DTO + Prisma model). Rationale: Consistent with all other API modules in the codebase.
|
||||
- ASSUMPTION: User preferences endpoint is part of the existing users module but route is not registered. Rationale: PRD lists it as an existing endpoint.
|
||||
|
||||
**Orchestrator Connectivity:**
|
||||
|
||||
- All orchestrator-proxied endpoints return HTTP 502
|
||||
- Orchestrator WebSocket connection fails ("Reconnecting to server...")
|
||||
- Dashboard widgets: Agent Status, Task Progress, Orchestrator Events all error
|
||||
- ASSUMPTION: The orchestrator service container runs but the Next.js API proxy cannot reach it. Root cause is likely environment variable or network configuration in Docker Swarm. Rationale: The orchestrator container exists in the compose file and has Traefik labels.
|
||||
|
||||
**UI/UX Issues:**
|
||||
|
||||
- Dark mode theming on Formality Level dropdown in Personalities page incorrect
|
||||
- favicon.ico missing (404)
|
||||
- Terminal sidebar link uses `#terminal` anchor instead of page route
|
||||
- `useWorkspaceId` warning in console: no workspace ID in localStorage on fresh sessions
|
||||
- ASSUMPTION: Terminal should have a dedicated page route `/terminal` that renders the terminal panel full-screen. Rationale: The sidebar has a Terminal link in the Operations section alongside Logs, implying it should be a navigable page.
|
||||
|
||||
**Credential Management:**
|
||||
|
||||
- "Add Credential" button is `disabled` in code — feature was stubbed as "coming soon"
|
||||
- Need to implement credential creation UI and wire to existing `/api/credentials` CRUD endpoints
|
||||
- ASSUMPTION: Credential CRUD frontend can use the existing `/api/credentials` API which was built during M7-CredentialSecurity. Rationale: Backend endpoints exist per audit.
|
||||
|
||||
### FR-021: Settings Configuration (Future — MS21)
|
||||
|
||||
- All environment variables configurable via UI
|
||||
- Minimal launch env vars, rest configurable dynamically
|
||||
@@ -496,10 +542,11 @@ These 19 NestJS modules are already implemented with Prisma and available for fr
|
||||
| MS16+MS17-PagesDataIntegration | 0.0.17 | All pages built + wired to real API data | COMPLETE |
|
||||
| MS18-ThemeWidgets | 0.0.18 | Theme package system, widget registry, WYSIWYG, Kanban filtering | COMPLETE |
|
||||
| MS19-ChatTerminal | 0.0.19 | Global terminal, project chat, master chat session | COMPLETE |
|
||||
| MS20-MultiTenant | 0.0.20 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
||||
| MS21-Federation | 0.0.21 | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
||||
| MS22-AgentTelemetry | 0.0.22 | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
|
||||
| MS23-Testing | 0.0.23 | Playwright E2E, federation tests, documentation finalization | NOT STARTED |
|
||||
| MS20-SiteStabilization | 0.0.20 | Runtime bug fixes, missing endpoints, orchestrator connectivity | IN PROGRESS |
|
||||
| MS21-MultiTenant | 0.0.21 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
||||
| MS22-Federation | 0.0.22 | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
||||
| MS23-AgentTelemetry | 0.0.23 | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
|
||||
| MS24-Testing | 0.0.24 | Playwright E2E, federation tests, documentation finalization | NOT STARTED |
|
||||
|
||||
## Assumptions
|
||||
|
||||
@@ -511,3 +558,9 @@ These 19 NestJS modules are already implemented with Prisma and available for fr
|
||||
6. ASSUMPTION: Theme packages are code-level TypeScript files (not runtime-installable npm packages). Each theme exports CSS variable overrides. Rationale: Keeps the system simple for MS18; runtime package loading can be added in a future milestone.
|
||||
7. ASSUMPTION: WYSIWYG editor uses Tiptap (ProseMirror-based, headless). Rationale: Headless approach integrates naturally with the CSS variable design system, excellent markdown import/export, TypeScript-first, battle-tested.
|
||||
8. ASSUMPTION: MS18 includes WYSIWYG editing for knowledge entries and Kanban filtering enhancements in addition to themes and widgets. These were originally listed separately but are grouped into MS18 per PRD scope items 24-25. Rationale: All are frontend-focused enhancements that build on the existing page infrastructure.
|
||||
9. ASSUMPTION: The `useWorkspaceId()` hook + auto-detect in `apiRequest` from PR #532 handles reads, but mutation endpoints on some pages don't pass workspace ID correctly. Rationale: GET requests work after PR #532 but POST/mutation requests still fail on domains and projects pages.
|
||||
10. ASSUMPTION: Personalities API follows existing NestJS module patterns (controller + service + DTO + Prisma model). Rationale: Consistent with all other API modules in the codebase.
|
||||
11. ASSUMPTION: User preferences endpoint is part of the existing users module but route is not registered. Rationale: PRD lists it as an existing endpoint.
|
||||
12. ASSUMPTION: The orchestrator service container runs but the Next.js API proxy cannot reach it. Root cause is likely environment variable or network configuration in Docker Swarm. Rationale: The orchestrator container exists in the compose file and has Traefik labels.
|
||||
13. ASSUMPTION: Terminal should have a dedicated page route `/terminal` that renders the terminal panel full-screen. Rationale: The sidebar has a Terminal link in the Operations section alongside Logs, implying it should be a navigable page.
|
||||
14. ASSUMPTION: Credential CRUD frontend can use the existing `/api/credentials` API which was built during M7-CredentialSecurity. Rationale: Backend endpoints exist per audit.
|
||||
|
||||
@@ -1,54 +1,38 @@
|
||||
# Tasks — MS19 Chat & Terminal System
|
||||
# Tasks — MS21 Multi-Tenant RBAC Data Migration
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
> Single-writer: orchestrator (Jarvis/OpenClaw) only. Workers read but never modify.
|
||||
|
||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||
| ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ------------------------------ | ----------------------------------------------- | ----------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ----------------------------------------------------------------- |
|
||||
| CT-PLAN-001 | done | Plan MS19 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | CT-TERM-001,CT-TERM-002,CT-CHAT-001,CT-CHAT-002 | orchestrator | 2026-02-25 | 2026-02-25 | 15K | ~15K | Planning complete |
|
||||
| CT-TERM-001 | done | Terminal WebSocket gateway & PTY session service — NestJS gateway (namespace: /terminal), node-pty spawn/kill/resize, workspace-scoped rooms, auth via token | #508 | api | feat/ms19-terminal-gateway | CT-PLAN-001 | CT-TERM-003,CT-TERM-004,CT-ORCH-002 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~30K | PR #515 merged (6290fc3), 48 tests |
|
||||
| CT-TERM-002 | done | Terminal session persistence — Prisma model (TerminalSession: id, workspaceId, name, status, createdAt, closedAt), migration, CRUD service | #508 | api | feat/ms19-terminal-persistence | CT-PLAN-001 | CT-TERM-004 | sonnet | 2026-02-25 | 2026-02-25 | 15K | ~15K | PR #517 merged (8128eb7), 12 tests, #508 closed |
|
||||
| CT-TERM-003 | done | xterm.js integration — Replace mock TerminalPanel with real xterm.js, WebSocket connection to /terminal namespace, resize handling, copy/paste, theme support | #509 | web | feat/ms19-xterm-integration | CT-TERM-001 | CT-TERM-004 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~30K | PR #518 merged (417c6ab), 40 tests |
|
||||
| CT-TERM-004 | done | Terminal tab management — Multiple named sessions, create/close/rename tabs, tab switching, session list from API, reconnect on page reload | #509 | web | feat/ms19-terminal-tabs | CT-TERM-001,CT-TERM-002,CT-TERM-003 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 20K | ~20K | PR #520 merged (859dcfc), 76 tests, #509 closed |
|
||||
| CT-CHAT-001 | done | Complete SSE chat streaming — Wire streamChatMessage() in frontend, token-by-token rendering in MessageList, streaming state indicators, abort/cancel support | #510 | web | feat/ms19-chat-streaming-v2 | CT-PLAN-001 | CT-CHAT-002,CT-ORCH-001 | sonnet | 2026-02-25 | 2026-02-25 | 25K | ~25K | PR #516 merged (7de0e73), streaming+fallback+abort |
|
||||
| CT-CHAT-002 | done | Master chat polish — Model selector dropdown, temperature/params config, conversation search in sidebar, keyboard shortcut improvements, empty state design | #510 | web | feat/ms19-chat-polish | CT-CHAT-001 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 15K | ~15K | PR #519 merged (13aa52a), 46 tests, #510 closed |
|
||||
| CT-ORCH-001 | done | Project-level orchestrator chat — Chat context scoped to project, command prefix parsing (/spawn, /status, /jobs, /kill), route commands through orchestrator proxy, display structured responses | #511 | web | feat/ms19-orchestrator-chat | CT-CHAT-001 | CT-ORCH-002,CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~25K | PR #521 merged (b110c46), 34 tests |
|
||||
| CT-ORCH-002 | done | Agent output in terminal — View orchestrator agent sessions as terminal tabs, stream agent stdout/stderr via SSE (/agents/events), agent lifecycle indicators (spawning/running/done) | #511 | web | feat/ms19-agent-terminal | CT-TERM-001,CT-ORCH-001 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 25K | ~25K | PR #522 merged (9b2520c), 79 tests, #511 closed |
|
||||
| CT-VER-001 | done | Unit tests — Tests for terminal gateway, xterm component, chat streaming, orchestrator chat, agent terminal integration | #512 | web,api | — | CT-TERM-004,CT-CHAT-002,CT-ORCH-001,CT-ORCH-002 | CT-DOC-001 | orchestrator | 2026-02-25 | 2026-02-25 | 20K | ~5K | 328 MS19 tests (268 web + 60 API), all inline with tasks |
|
||||
| CT-DOC-001 | done | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #512 | — | — | CT-VER-001 | CT-VER-002 | orchestrator | 2026-02-25 | 2026-02-25 | 10K | ~5K | Updated PRD, manifest, scratchpad, TASKS.md |
|
||||
| CT-VER-002 | done | Deploy + smoke test — Deploy to Portainer, verify terminal, chat streaming, orchestrator chat, agent output all functional | #512 | — | — | CT-DOC-001 | | orchestrator | 2026-02-25 | 2026-02-25 | 15K | ~5K | CI #635 green, web deployed (sha:7165e7a), API crash pre-existing |
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
| --------------- | ----------------- |
|
||||
| Total tasks | 12 |
|
||||
| Completed | 12 |
|
||||
| In Progress | 0 |
|
||||
| Remaining | 0 |
|
||||
| Estimated total | ~250K tokens |
|
||||
| Used | ~215K tokens |
|
||||
| Milestone | MS19-ChatTerminal |
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
PLAN-001 ──┬──→ TERM-001 ──┬──→ TERM-003 ──→ TERM-004 ──→ VER-001 ──→ DOC-001 ──→ VER-002
|
||||
│ │ ↑
|
||||
│ └──→ ORCH-002 ───────┘
|
||||
│ ↑
|
||||
├──→ TERM-002 ────────→ TERM-004
|
||||
│
|
||||
├──→ CHAT-001 ──┬──→ CHAT-002 ──→ VER-001
|
||||
│ │
|
||||
│ └──→ ORCH-001 ──→ ORCH-002
|
||||
│
|
||||
└──→ CHAT-002 (also depends on CHAT-001)
|
||||
```
|
||||
|
||||
## Parallel Execution Opportunities
|
||||
|
||||
- **Wave 1** (after PLAN-001): TERM-001 + TERM-002 + CHAT-001 can run in parallel (3 independent tracks)
|
||||
- **Wave 2**: TERM-003 (after TERM-001) + CHAT-002 (after CHAT-001) + ORCH-001 (after CHAT-001) can overlap
|
||||
- **Wave 3**: TERM-004 (after TERM-001+002+003) + ORCH-002 (after TERM-001+ORCH-001)
|
||||
- **Wave 4**: VER-001 (after all implementation)
|
||||
- **Wave 5**: DOC-001 → VER-002 (sequential)
|
||||
| id | status | milestone | description | pr | agent | notes |
|
||||
|----|--------|-----------|-------------|----|-------|-------|
|
||||
| MS21-PLAN-001 | done | phase-1 | Write PRD, init mission, populate TASKS.md | #552 | orchestrator | CI: #552 green |
|
||||
| MS21-DB-001 | done | phase-1 | Prisma migration: add user fields | #553 | claude-worker-1 | CI: #684 green |
|
||||
| MS21-API-001 | done | phase-1 | AdminModule with user/workspace admin endpoints | #555 | claude-worker-2 | CI: #689 green |
|
||||
| MS21-API-002 | done | phase-1 | Admin user endpoints (list, invite, update, deactivate) | #555 | claude-worker-2 | Combined with API-001 |
|
||||
| MS21-API-003 | done | phase-1 | Workspace member management endpoints | #556 | codex-worker-1 | CI: #700 green |
|
||||
| MS21-API-004 | done | phase-1 | Team management module | #564 | codex-worker-2 | CI: #707 green |
|
||||
| MS21-API-005 | done | phase-1 | Admin workspace endpoints | #555 | claude-worker-2 | Combined with API-001 |
|
||||
| MS21-TEST-001 | done | phase-1 | Unit tests for AdminService and AdminController | #555 | claude-worker-2 | 26 tests included |
|
||||
| MS21-AUTH-001 | done | phase-2 | LocalAuthModule: break-glass auth | #559 | claude-worker-3 | CI: #691 green |
|
||||
| MS21-AUTH-002 | done | phase-2 | Break-glass setup endpoint | #559 | claude-worker-3 | Combined with AUTH-001 |
|
||||
| MS21-AUTH-003 | done | phase-2 | Break-glass login endpoint | #559 | claude-worker-3 | Combined with AUTH-001 |
|
||||
| MS21-AUTH-004 | not-started | phase-2 | Deactivation session invalidation | — | — | Deferred |
|
||||
| MS21-TEST-002 | done | phase-2 | Unit tests for LocalAuth | #559 | claude-worker-3 | 27 tests included |
|
||||
| MS21-MIG-001 | done | phase-3 | Migration script: scripts/migrate-brain.ts | #554 | codex-worker-1 | CI: #688 (test flaky, code clean) |
|
||||
| MS21-MIG-002 | done | phase-3 | Migration mapping: status/priority/domain mapping | #554 | codex-worker-1 | Included in MIG-001 |
|
||||
| MS21-MIG-003 | not-started | phase-3 | Migration execution: run on production database | — | — | Needs deploy |
|
||||
| MS21-MIG-004 | not-started | phase-3 | Import API endpoints | — | — | |
|
||||
| MS21-TEST-003 | not-started | phase-3 | Migration script tests | — | — | |
|
||||
| MS21-UI-001 | not-started | phase-4 | Settings/users page | — | — | |
|
||||
| MS21-UI-002 | not-started | phase-4 | User detail/edit and invite dialogs | — | — | |
|
||||
| MS21-UI-003 | not-started | phase-4 | Settings/workspaces page (wire to real API) | — | — | Mock data exists |
|
||||
| MS21-UI-004 | not-started | phase-4 | Workspace member management UI | — | — | Components exist |
|
||||
| MS21-UI-005 | not-started | phase-4 | Settings/teams page | — | — | |
|
||||
| MS21-TEST-004 | not-started | phase-4 | Frontend component tests | — | — | |
|
||||
| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation role gating | — | — | |
|
||||
| MS21-RBAC-002 | not-started | phase-5 | Settings page access restriction | — | — | |
|
||||
| MS21-RBAC-003 | not-started | phase-5 | Action button permission gating | — | — | |
|
||||
| MS21-RBAC-004 | not-started | phase-5 | User profile role display | — | — | |
|
||||
| MS21-VER-001 | not-started | phase-6 | Full quality gate pass | — | — | |
|
||||
| MS21-VER-002 | not-started | phase-6 | Deploy and smoke test | — | — | |
|
||||
| MS21-VER-003 | not-started | phase-6 | Tag v0.0.21 | — | — | |
|
||||
| MS21-FIX-001 | done | phase-1 | Fix flaky CI tests (rate limit timeout + log sanitizer) | #562 | codex-worker-3 | CI: #705 green |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user