Compare commits

..

2 Commits

Author SHA1 Message Date
e41fedb3c2 feat(api): add terminal session persistence with Prisma model and CRUD service
Some checks failed
ci/woodpecker/push/api Pipeline failed
Adds database-backed TerminalSession model with ACTIVE/CLOSED status enum,
migration SQL, TerminalSessionService (create/findByWorkspace/close/findById),
DTO file with class-validator decorators, unit tests (12 tests), and module
registration. Workspace relation and indexed columns enable efficient session
listing and recovery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:46:58 -06:00
5ba77d8952 fix(web): add random suffix to fallback assistant message IDs to prevent collisions
Some checks failed
ci/woodpecker/push/web Pipeline failed
Message IDs generated in the fallback path used only Date.now() which caused
ID collisions in rapid-send scenarios (multiple sends within the same millisecond).
Adding the same Math.random() suffix used by userMessage IDs ensures uniqueness.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:41:43 -06:00
165 changed files with 1500 additions and 19477 deletions

View File

@@ -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://mosaic-api.woltje.com/auth/oauth2/callback/authentik
# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
# Authentik PostgreSQL Database
@@ -314,19 +314,17 @@ COORDINATOR_ENABLED=true
# TTL is in seconds, limits are per TTL window
# Global rate limit (applies to all endpoints unless overridden)
# Time window in seconds
RATE_LIMIT_TTL=60
# Requests per window
RATE_LIMIT_GLOBAL_LIMIT=100
RATE_LIMIT_TTL=60 # Time window in seconds
RATE_LIMIT_GLOBAL_LIMIT=100 # Requests per window
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute
RATE_LIMIT_WEBHOOK_LIMIT=60
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch)
RATE_LIMIT_WEBHOOK_LIMIT=60 # Requests per minute
# Coordinator endpoints (/coordinator/*) — requests per minute
RATE_LIMIT_COORDINATOR_LIMIT=100
# Coordinator endpoints (/coordinator/*)
RATE_LIMIT_COORDINATOR_LIMIT=100 # Requests per minute
# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring)
RATE_LIMIT_HEALTH_LIMIT=300
# Health check endpoints (/coordinator/health)
RATE_LIMIT_HEALTH_LIMIT=300 # Requests per minute (higher for monitoring)
# Storage backend for rate limiting (redis or memory)
# redis: Uses Valkey for distributed rate limiting (recommended for production)
@@ -361,17 +359,17 @@ RATE_LIMIT_STORAGE=redis
# a single workspace.
MATRIX_HOMESERVER_URL=http://synapse:8008
MATRIX_ACCESS_TOKEN=
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
MATRIX_SERVER_NAME=matrix.woltje.com
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.example.com
MATRIX_SERVER_NAME=matrix.example.com
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.example.com
# MATRIX_WORKSPACE_ID=your-workspace-uuid
# ======================
# Matrix / Synapse Deployment
# ======================
# Domains for Traefik routing to Matrix services
MATRIX_DOMAIN=matrix.woltje.com
ELEMENT_DOMAIN=chat.woltje.com
MATRIX_DOMAIN=matrix.example.com
ELEMENT_DOMAIN=chat.example.com
# Synapse database (created automatically by synapse-db-init in the swarm compose)
SYNAPSE_POSTGRES_DB=synapse

View File

@@ -1,90 +1,14 @@
{
"schema_version": 1,
"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",
"mission_id": "prd-implementation-20260222",
"name": "PRD implementation",
"description": "",
"project_path": "/home/jwoltje/src/mosaic-stack",
"created_at": "2026-02-28T17:10:22Z",
"created_at": "2026-02-23T03:20:55Z",
"status": "active",
"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": ""
},
{
"session_id": "sess-002",
"runtime": "unknown",
"started_at": "2026-02-28T20:30:13Z",
"ended_at": "",
"ended_reason": "",
"milestone_at_end": "",
"tasks_completed": [],
"last_task_id": ""
}
]
"task_prefix": "",
"quality_gates": "",
"milestone_version": "0.0.1",
"milestones": [],
"sessions": []
}

View File

@@ -1,8 +0,0 @@
{
"session_id": "sess-002",
"runtime": "unknown",
"pid": 3178395,
"started_at": "2026-02-28T20:30:13Z",
"project_path": "/tmp/ms21-ui-001",
"milestone_id": ""
}

View File

@@ -24,13 +24,6 @@ variables:
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
- &turbo_env
TURBO_API:
from_secret: turbo_api
TURBO_TOKEN:
from_secret: turbo_token
TURBO_TEAM:
from_secret: turbo_team
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
@@ -59,6 +52,17 @@ steps:
depends_on:
- install
lint:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/api" lint
depends_on:
- prisma-generate
- build-shared
prisma-generate:
image: *node_image
environment:
@@ -69,27 +73,26 @@ steps:
depends_on:
- install
lint:
build-shared:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo lint --filter=@mosaic/api
- pnpm --filter "@mosaic/shared" build
depends_on:
- prisma-generate
- install
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo typecheck --filter=@mosaic/api
- pnpm --filter "@mosaic/api" typecheck
depends_on:
- prisma-generate
- build-shared
prisma-migrate:
image: *node_image
@@ -121,7 +124,6 @@ steps:
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo build --filter=@mosaic/api

View File

@@ -24,13 +24,6 @@ variables:
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
- &turbo_env
TURBO_API:
from_secret: turbo_api
TURBO_TOKEN:
from_secret: turbo_token
TURBO_TEAM:
from_secret: turbo_team
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
@@ -55,10 +48,9 @@ steps:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo lint --filter=@mosaic/orchestrator
- pnpm --filter "@mosaic/orchestrator" lint
depends_on:
- install
@@ -66,10 +58,9 @@ steps:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo typecheck --filter=@mosaic/orchestrator
- pnpm --filter "@mosaic/orchestrator" typecheck
depends_on:
- install
@@ -77,10 +68,9 @@ steps:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo test --filter=@mosaic/orchestrator
- pnpm --filter "@mosaic/orchestrator" test
depends_on:
- install
@@ -91,7 +81,6 @@ steps:
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo build --filter=@mosaic/orchestrator

View File

@@ -24,13 +24,6 @@ variables:
pnpm install --frozen-lockfile
- &use_deps |
corepack enable
- &turbo_env
TURBO_API:
from_secret: turbo_api
TURBO_TOKEN:
from_secret: turbo_token
TURBO_TEAM:
from_secret: turbo_team
- &kaniko_setup |
mkdir -p /kaniko/.docker
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
@@ -51,38 +44,46 @@ steps:
depends_on:
- install
build-shared:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
commands:
- *use_deps
- pnpm --filter "@mosaic/shared" build
- pnpm --filter "@mosaic/ui" build
depends_on:
- install
lint:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo lint --filter=@mosaic/web
- pnpm --filter "@mosaic/web" lint
depends_on:
- install
- build-shared
typecheck:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo typecheck --filter=@mosaic/web
- pnpm --filter "@mosaic/web" typecheck
depends_on:
- install
- build-shared
test:
image: *node_image
environment:
SKIP_ENV_VALIDATION: "true"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo test --filter=@mosaic/web
- pnpm --filter "@mosaic/web" test
depends_on:
- install
- build-shared
# === Build ===
@@ -91,7 +92,6 @@ steps:
environment:
SKIP_ENV_VALIDATION: "true"
NODE_ENV: "production"
<<: *turbo_env
commands:
- *use_deps
- pnpm turbo build --filter=@mosaic/web

View File

@@ -46,21 +46,6 @@ pnpm lint
pnpm build
```
## Versioning Protocol (HARD GATE)
**This project is ALPHA. All versions MUST be `0.0.x`.**
- The `0.1.0` release is FORBIDDEN until Jason explicitly authorizes it.
- Every milestone bump increments the patch: `0.0.20``0.0.21``0.0.22`, etc.
- ALL package.json files in the monorepo MUST stay in sync at the same version.
- Use `scripts/version-bump.sh <version>` to bump — it enforces the alpha constraint and updates all packages atomically.
- The script rejects any version >= `0.1.0`.
- When creating a release tag, the tag MUST match the package version: `v0.0.x`.
**Milestone-to-version mapping** is defined in the PRD (`docs/PRD.md`) under "Delivery/Milestone Intent". Agents MUST use the version from that table when tagging a milestone release.
**Violation of this protocol is a blocking error.** If an agent attempts to set a version >= `0.1.0`, stop and escalate.
## Standards and Quality
- Enforce strict typing and no unsafe shortcuts.

View File

@@ -18,12 +18,6 @@ COPY turbo.json ./
# ======================
FROM base AS deps
# Install build tools for native addons (node-pty requires node-gyp compilation)
# and OpenSSL for Prisma engine detection
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ openssl \
&& rm -rf /var/lib/apt/lists/*
# Copy all package.json files for workspace resolution
COPY packages/shared/package.json ./packages/shared/
COPY packages/ui/package.json ./packages/ui/
@@ -31,11 +25,7 @@ COPY packages/config/package.json ./packages/config/
COPY apps/api/package.json ./apps/api/
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
# scripts or fail to find prebuilt binaries for this Node.js version
RUN pnpm install --frozen-lockfile \
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
&& npx node-gyp rebuild 2>&1 || true
RUN pnpm install --frozen-lockfile
# ======================
# Builder stage
@@ -68,11 +58,7 @@ FROM node:24-slim AS production
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
# - openssl: Prisma engine detection requires libssl
# - No build tools needed here — native addons are compiled in the deps stage
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/* \
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
&& chmod 755 /usr/local/bin/dumb-init \
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaic/api",
"version": "0.0.20",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "nest build",
@@ -52,7 +52,6 @@
"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",
@@ -86,7 +85,6 @@
"@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",

View File

@@ -1,3 +0,0 @@
-- 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';

View File

@@ -3,7 +3,6 @@
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"]
}
@@ -227,14 +226,6 @@ 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[]
@@ -1076,10 +1067,6 @@ 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

View File

@@ -1,258 +0,0 @@
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 });
});
});
});

View File

@@ -1,64 +0,0 @@
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);
}
}

View File

@@ -1,13 +0,0 @@
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 {}

View File

@@ -1,477 +0,0 @@
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(),
},
session: {
deleteMany: vi.fn(),
},
$transaction: vi.fn(async (ops) => {
if (typeof ops === "function") {
return ops(mockPrismaService);
}
return Promise.all(ops);
}),
};
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();
});
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 and invalidate sessions", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
deactivatedAt: new Date(),
});
mockPrismaService.session.deleteMany.mockResolvedValue({ count: 3 });
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) },
})
);
expect(mockPrismaService.session.deleteMany).toHaveBeenCalledWith({ where: { userId: mockUserId } });
});
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" },
})
);
});
});
});

View File

@@ -1,309 +0,0 @@
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.$transaction([
this.prisma.user.update({
where: { id },
data: { deactivatedAt: new Date() },
include: {
workspaceMemberships: {
include: {
workspace: { select: { id: true, name: true } },
},
},
},
}),
this.prisma.session.deleteMany({ where: { userId: id } }),
]);
this.logger.log(`User deactivated and sessions invalidated: ${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,
};
}
}

View File

@@ -1,15 +0,0 @@
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>;
}

View File

@@ -1,20 +0,0 @@
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;
}

View File

@@ -1,15 +0,0 @@
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;
}

View File

@@ -1,17 +0,0 @@
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;
}

View File

@@ -1,27 +0,0 @@
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>;
}

View File

@@ -1,13 +0,0 @@
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>;
}

View File

@@ -1,49 +0,0 @@
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;
}

View File

@@ -41,11 +41,6 @@ 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 { ImportModule } from "./import/import.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({
@@ -110,11 +105,6 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
SpeechModule,
DashboardModule,
TerminalModule,
PersonalitiesModule,
WorkspacesModule,
AdminModule,
TeamsModule,
ImportModule,
],
controllers: [AppController, CsrfController],
providers: [

View File

@@ -361,13 +361,16 @@ describe("AuthController", () => {
});
describe("getProfile", () => {
it("should return complete user profile with identity fields", () => {
it("should return complete user profile with workspace 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);
@@ -378,10 +381,13 @@ 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 only required fields", () => {
it("should return user profile with optional fields undefined", () => {
const mockUser: AuthUser = {
id: "user-123",
email: "test@example.com",
@@ -394,11 +400,12 @@ 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");
});
});

View File

@@ -72,10 +72,15 @@ export class AuthController {
if (user.emailVerified !== undefined) {
profile.emailVerified = user.emailVerified;
}
// 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.
if (user.workspaceId !== undefined) {
profile.workspaceId = user.workspaceId;
}
if (user.currentWorkspaceId !== undefined) {
profile.currentWorkspaceId = user.currentWorkspaceId;
}
if (user.workspaceRole !== undefined) {
profile.workspaceRole = user.workspaceRole;
}
return profile;
}

View File

@@ -3,14 +3,11 @@ 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, LocalAuthController],
providers: [AuthService, AuthGuard, LocalAuthService, LocalAuthEnabledGuard],
controllers: [AuthController],
providers: [AuthService, AuthGuard],
exports: [AuthService, AuthGuard],
})
export class AuthModule {}

View File

@@ -1,10 +0,0 @@
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;
}

View File

@@ -1,20 +0,0 @@
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;
}

View File

@@ -1,232 +0,0 @@
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);
});
});

View File

@@ -1,81 +0,0 @@
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";
}
}

View File

@@ -1,15 +0,0 @@
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;
}
}

View File

@@ -1,389 +0,0 @@
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");
}
});
});
});

View File

@@ -1,230 +0,0 @@
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 };
}
}

View File

@@ -110,10 +110,10 @@ export class WorkspaceGuard implements CanActivate {
return paramWorkspaceId;
}
// 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;
// 3. Check request body
const bodyWorkspaceId = request.body.workspaceId;
if (typeof bodyWorkspaceId === "string") {
return bodyWorkspaceId;
}
// 4. Check query string (backward compatibility for existing clients)

View File

@@ -270,7 +270,7 @@ describe("sanitizeForLogging", () => {
const duration = Date.now() - start;
expect(result.password).toBe("[REDACTED]");
expect(duration).toBeLessThan(500); // Should complete in under 500ms
expect(duration).toBeLessThan(100); // Should complete in under 100ms
});
});

View File

@@ -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", () => {

View File

@@ -1,89 +0,0 @@
import { IsNumber, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
/**
* DTO for a single jarvis-brain project record.
* This matches the project object shape consumed by scripts/migrate-brain.ts.
*/
export class ImportProjectDto {
@IsString({ message: "id must be a string" })
@MinLength(1, { message: "id must not be empty" })
@MaxLength(255, { message: "id must not exceed 255 characters" })
id!: 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;
@IsOptional()
@IsString({ message: "description must be a string" })
description?: string | null;
@IsOptional()
@IsString({ message: "domain must be a string" })
domain?: string | null;
@IsOptional()
@IsString({ message: "status must be a string" })
status?: string | null;
// jarvis-brain project priority can be a number, string, or null
@IsOptional()
priority?: number | string | null;
@IsOptional()
@IsNumber({}, { message: "progress must be a number" })
progress?: number | null;
@IsOptional()
@IsString({ message: "repo must be a string" })
repo?: string | null;
@IsOptional()
@IsString({ message: "branch must be a string" })
branch?: string | null;
@IsOptional()
@IsString({ message: "current_milestone must be a string" })
current_milestone?: string | null;
@IsOptional()
@IsString({ message: "next_milestone must be a string" })
next_milestone?: string | null;
@IsOptional()
@IsString({ message: "blocker must be a string" })
blocker?: string | null;
@IsOptional()
@IsString({ message: "owner must be a string" })
owner?: string | null;
@IsOptional()
@IsString({ message: "docs_path must be a string" })
docs_path?: string | null;
@IsOptional()
@IsString({ message: "created must be a string" })
created?: string | null;
@IsOptional()
@IsString({ message: "updated must be a string" })
updated?: string | null;
@IsOptional()
@IsString({ message: "target_date must be a string" })
target_date?: string | null;
@IsOptional()
@IsString({ message: "notes must be a string" })
notes?: string | null;
@IsOptional()
@IsString({ message: "notes_nontechnical must be a string" })
notes_nontechnical?: string | null;
@IsOptional()
@IsString({ message: "parent must be a string" })
parent?: string | null;
}

View File

@@ -1,5 +0,0 @@
export interface ImportResponseDto {
imported: number;
skipped: number;
errors: string[];
}

View File

@@ -1,76 +0,0 @@
import { IsArray, IsNumber, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
/**
* DTO for a single jarvis-brain task record.
* This matches the task object shape consumed by scripts/migrate-brain.ts.
*/
export class ImportTaskDto {
@IsString({ message: "id must be a string" })
@MinLength(1, { message: "id must not be empty" })
@MaxLength(255, { message: "id must not exceed 255 characters" })
id!: string;
@IsString({ message: "title must be a string" })
@MinLength(1, { message: "title must not be empty" })
@MaxLength(255, { message: "title must not exceed 255 characters" })
title!: string;
@IsOptional()
@IsString({ message: "domain must be a string" })
domain?: string | null;
@IsOptional()
@IsString({ message: "project must be a string" })
project?: string | null;
@IsOptional()
@IsArray({ message: "related must be an array" })
@IsString({ each: true, message: "related items must be strings" })
related?: string[];
@IsOptional()
@IsString({ message: "priority must be a string" })
priority?: string | null;
@IsOptional()
@IsString({ message: "status must be a string" })
status?: string | null;
@IsOptional()
@IsNumber({}, { message: "progress must be a number" })
progress?: number | null;
@IsOptional()
@IsString({ message: "due must be a string" })
due?: string | null;
@IsOptional()
@IsArray({ message: "blocks must be an array" })
@IsString({ each: true, message: "blocks items must be strings" })
blocks?: string[];
@IsOptional()
@IsArray({ message: "blocked_by must be an array" })
@IsString({ each: true, message: "blocked_by items must be strings" })
blocked_by?: string[];
@IsOptional()
@IsString({ message: "assignee must be a string" })
assignee?: string | null;
@IsOptional()
@IsString({ message: "created must be a string" })
created?: string | null;
@IsOptional()
@IsString({ message: "updated must be a string" })
updated?: string | null;
@IsOptional()
@IsString({ message: "notes must be a string" })
notes?: string | null;
@IsOptional()
@IsString({ message: "notes_nontechnical must be a string" })
notes_nontechnical?: string | null;
}

View File

@@ -1,3 +0,0 @@
export { ImportTaskDto } from "./import-task.dto";
export { ImportProjectDto } from "./import-project.dto";
export type { ImportResponseDto } from "./import-response.dto";

View File

@@ -1,33 +0,0 @@
import { Body, Controller, ParseArrayPipe, Post, UseGuards } from "@nestjs/common";
import type { AuthUser } from "@mosaic/shared";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import { AdminGuard } from "../auth/guards/admin.guard";
import { AuthGuard } from "../auth/guards/auth.guard";
import { Workspace } from "../common/decorators";
import { WorkspaceGuard } from "../common/guards";
import { ImportProjectDto, type ImportResponseDto, ImportTaskDto } from "./dto";
import { ImportService } from "./import.service";
@Controller("import")
@UseGuards(AuthGuard, WorkspaceGuard, AdminGuard)
export class ImportController {
constructor(private readonly importService: ImportService) {}
@Post("tasks")
async importTasks(
@Body(new ParseArrayPipe({ items: ImportTaskDto })) taskPayload: ImportTaskDto[],
@Workspace() workspaceId: string,
@CurrentUser() user: AuthUser
): Promise<ImportResponseDto> {
return this.importService.importTasks(workspaceId, user.id, taskPayload);
}
@Post("projects")
async importProjects(
@Body(new ParseArrayPipe({ items: ImportProjectDto })) projectPayload: ImportProjectDto[],
@Workspace() workspaceId: string,
@CurrentUser() user: AuthUser
): Promise<ImportResponseDto> {
return this.importService.importProjects(workspaceId, user.id, projectPayload);
}
}

View File

@@ -1,13 +0,0 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { PrismaModule } from "../prisma/prisma.module";
import { ImportController } from "./import.controller";
import { ImportService } from "./import.service";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [ImportController],
providers: [ImportService],
exports: [ImportService],
})
export class ImportModule {}

View File

@@ -1,251 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ProjectStatus, TaskPriority, TaskStatus } from "@prisma/client";
import { ImportService } from "./import.service";
import { PrismaService } from "../prisma/prisma.service";
describe("ImportService", () => {
let service: ImportService;
const mockPrismaService = {
withWorkspaceContext: vi.fn(),
domain: {
findUnique: vi.fn(),
create: vi.fn(),
},
project: {
findFirst: vi.fn(),
create: vi.fn(),
},
task: {
findFirst: vi.fn(),
create: vi.fn(),
},
};
const workspaceId = "550e8400-e29b-41d4-a716-446655440001";
const userId = "550e8400-e29b-41d4-a716-446655440002";
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ImportService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<ImportService>(ImportService);
vi.clearAllMocks();
mockPrismaService.withWorkspaceContext.mockImplementation(
async (_userId: string, _workspaceId: string, fn: (client: unknown) => Promise<unknown>) => {
return fn(mockPrismaService);
}
);
});
it("should be defined", () => {
expect(service).toBeDefined();
});
describe("importTasks", () => {
it("maps status/priority/domain and imports a task", async () => {
mockPrismaService.task.findFirst.mockResolvedValue(null);
mockPrismaService.domain.findUnique.mockResolvedValue(null);
mockPrismaService.domain.create.mockResolvedValue({ id: "domain-id" });
mockPrismaService.project.findFirst.mockResolvedValue(null);
mockPrismaService.task.create.mockResolvedValue({ id: "task-id" });
const result = await service.importTasks(workspaceId, userId, [
{
id: "task-1",
title: "Import me",
domain: "Platform Ops",
status: "in-progress",
priority: "critical",
project: null,
related: [],
blocks: [],
blocked_by: [],
progress: 42,
due: "2026-03-15",
created: "2026-03-01T10:00:00.000Z",
updated: "2026-03-05T12:00:00.000Z",
assignee: null,
notes: "notes",
notes_nontechnical: "non technical",
},
]);
expect(result).toEqual({ imported: 1, skipped: 0, errors: [] });
expect(mockPrismaService.task.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
title: "Import me",
status: TaskStatus.IN_PROGRESS,
priority: TaskPriority.HIGH,
domainId: "domain-id",
}),
})
);
});
it("skips existing task by brainId", async () => {
mockPrismaService.task.findFirst.mockResolvedValue({ id: "existing-task-id" });
const result = await service.importTasks(workspaceId, userId, [
{
id: "task-1",
title: "Existing",
domain: null,
status: "pending",
priority: "medium",
project: null,
related: [],
blocks: [],
blocked_by: [],
progress: null,
due: null,
created: null,
updated: null,
assignee: null,
notes: null,
notes_nontechnical: null,
},
]);
expect(result.imported).toBe(0);
expect(result.skipped).toBe(1);
expect(mockPrismaService.task.create).not.toHaveBeenCalled();
});
it("collects mapping/missing-project errors while importing", async () => {
mockPrismaService.task.findFirst.mockResolvedValue(null);
mockPrismaService.project.findFirst.mockResolvedValue(null);
mockPrismaService.task.create.mockResolvedValue({ id: "task-id" });
const result = await service.importTasks(workspaceId, userId, [
{
id: "task-1",
title: "Needs project",
domain: null,
status: "mystery-status",
priority: "mystery-priority",
project: "brain-project-1",
related: [],
blocks: [],
blocked_by: [],
progress: null,
due: null,
created: null,
updated: null,
assignee: null,
notes: null,
notes_nontechnical: null,
},
]);
expect(result.imported).toBe(1);
expect(result.errors).toEqual(
expect.arrayContaining([
expect.stringContaining('Unknown task status "mystery-status"'),
expect.stringContaining('Unknown task priority "mystery-priority"'),
expect.stringContaining('referenced project "brain-project-1" not found'),
])
);
expect(mockPrismaService.task.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: TaskStatus.NOT_STARTED,
priority: TaskPriority.MEDIUM,
projectId: null,
}),
})
);
});
});
describe("importProjects", () => {
it("maps status/domain and imports a project", async () => {
mockPrismaService.project.findFirst.mockResolvedValue(null);
mockPrismaService.domain.findUnique.mockResolvedValue(null);
mockPrismaService.domain.create.mockResolvedValue({ id: "domain-id" });
mockPrismaService.project.create.mockResolvedValue({ id: "project-id" });
const result = await service.importProjects(workspaceId, userId, [
{
id: "project-1",
name: "Project One",
description: "desc",
domain: "Backend",
status: "in-progress",
priority: "high",
progress: 50,
repo: "git@example.com/repo",
branch: "main",
current_milestone: "MS21",
next_milestone: "MS22",
blocker: null,
owner: "owner",
docs_path: "docs/PRD.md",
created: "2026-03-01",
updated: "2026-03-05",
target_date: "2026-04-01",
notes: "notes",
notes_nontechnical: "non tech",
parent: null,
},
]);
expect(result).toEqual({ imported: 1, skipped: 0, errors: [] });
expect(mockPrismaService.project.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
name: "Project One",
status: ProjectStatus.ACTIVE,
domainId: "domain-id",
}),
})
);
});
it("captures create failures as errors", async () => {
mockPrismaService.project.findFirst.mockResolvedValue(null);
mockPrismaService.project.create.mockRejectedValue(new Error("db failed"));
const result = await service.importProjects(workspaceId, userId, [
{
id: "project-1",
name: "Project One",
description: null,
domain: null,
status: "planning",
priority: null,
progress: null,
repo: null,
branch: null,
current_milestone: null,
next_milestone: null,
blocker: null,
owner: null,
docs_path: null,
created: null,
updated: null,
target_date: null,
notes: null,
notes_nontechnical: null,
parent: null,
},
]);
expect(result.imported).toBe(0);
expect(result.skipped).toBe(1);
expect(result.errors).toEqual([
expect.stringContaining("project project-1: failed to import: db failed"),
]);
});
});
});

View File

@@ -1,496 +0,0 @@
import { Injectable } from "@nestjs/common";
import { Prisma, PrismaClient, ProjectStatus, TaskPriority, TaskStatus } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import type { ImportProjectDto, ImportResponseDto, ImportTaskDto } from "./dto";
interface TaskStatusMapping {
status: TaskStatus;
issue: string | null;
}
interface TaskPriorityMapping {
priority: TaskPriority;
issue: string | null;
}
interface ProjectStatusMapping {
status: ProjectStatus;
issue: string | null;
}
@Injectable()
export class ImportService {
constructor(private readonly prisma: PrismaService) {}
async importTasks(
workspaceId: string,
userId: string,
taskPayload: ImportTaskDto[]
): Promise<ImportResponseDto> {
const errors: string[] = [];
let imported = 0;
let skipped = 0;
const importTimestamp = new Date().toISOString();
const seenBrainTaskIds = new Set<string>();
const domainIdBySlug = new Map<string, string>();
const projectIdByBrainId = new Map<string, string | null>();
await this.prisma.withWorkspaceContext(userId, workspaceId, async (tx: PrismaClient) => {
for (const [index, task] of taskPayload.entries()) {
const brainId = task.id.trim();
if (seenBrainTaskIds.has(brainId)) {
skipped += 1;
errors.push(`task ${brainId}: duplicate item in request body`);
continue;
}
seenBrainTaskIds.add(brainId);
try {
const existingTask = await tx.task.findFirst({
where: {
workspaceId,
metadata: {
path: ["brainId"],
equals: brainId,
},
},
select: { id: true },
});
if (existingTask) {
skipped += 1;
continue;
}
const mappedStatus = this.mapTaskStatus(task.status ?? null);
if (mappedStatus.issue) {
errors.push(`task ${brainId}: ${mappedStatus.issue}`);
}
const mappedPriority = this.mapTaskPriority(task.priority ?? null);
if (mappedPriority.issue) {
errors.push(`task ${brainId}: ${mappedPriority.issue}`);
}
const projectBrainId = task.project?.trim() ? task.project.trim() : null;
const projectId = await this.resolveProjectId(
tx,
workspaceId,
projectBrainId,
projectIdByBrainId,
brainId,
errors
);
const domainId = await this.resolveDomainId(
tx,
workspaceId,
task.domain ?? null,
importTimestamp,
domainIdBySlug
);
const createdAt =
this.normalizeDate(task.created ?? null, `task ${brainId}.created`, errors) ??
new Date();
const updatedAt =
this.normalizeDate(task.updated ?? null, `task ${brainId}.updated`, errors) ??
createdAt;
const dueDate = this.normalizeDate(task.due ?? null, `task ${brainId}.due`, errors);
const completedAt = mappedStatus.status === TaskStatus.COMPLETED ? updatedAt : null;
const metadata = this.asJsonValue({
source: "jarvis-brain",
brainId,
brainDomain: task.domain ?? null,
brainProjectId: projectBrainId,
rawStatus: task.status ?? null,
rawPriority: task.priority ?? null,
related: task.related ?? [],
blocks: task.blocks ?? [],
blockedBy: task.blocked_by ?? [],
assignee: task.assignee ?? null,
progress: task.progress ?? null,
notes: task.notes ?? null,
notesNonTechnical: task.notes_nontechnical ?? null,
importedAt: importTimestamp,
});
await tx.task.create({
data: {
workspaceId,
title: task.title,
description: task.notes ?? null,
status: mappedStatus.status,
priority: mappedPriority.priority,
dueDate,
creatorId: userId,
projectId,
domainId,
metadata,
createdAt,
updatedAt,
completedAt,
},
});
imported += 1;
} catch (error) {
skipped += 1;
errors.push(
`task ${brainId || `index-${String(index)}`}: failed to import: ${this.getErrorMessage(error)}`
);
}
}
});
return {
imported,
skipped,
errors,
};
}
async importProjects(
workspaceId: string,
userId: string,
projectPayload: ImportProjectDto[]
): Promise<ImportResponseDto> {
const errors: string[] = [];
let imported = 0;
let skipped = 0;
const importTimestamp = new Date().toISOString();
const seenBrainProjectIds = new Set<string>();
const domainIdBySlug = new Map<string, string>();
await this.prisma.withWorkspaceContext(userId, workspaceId, async (tx: PrismaClient) => {
for (const [index, project] of projectPayload.entries()) {
const brainId = project.id.trim();
if (seenBrainProjectIds.has(brainId)) {
skipped += 1;
errors.push(`project ${brainId}: duplicate item in request body`);
continue;
}
seenBrainProjectIds.add(brainId);
try {
const existingProject = await tx.project.findFirst({
where: {
workspaceId,
metadata: {
path: ["brainId"],
equals: brainId,
},
},
select: { id: true },
});
if (existingProject) {
skipped += 1;
continue;
}
const mappedStatus = this.mapProjectStatus(project.status ?? null);
if (mappedStatus.issue) {
errors.push(`project ${brainId}: ${mappedStatus.issue}`);
}
const domainId = await this.resolveDomainId(
tx,
workspaceId,
project.domain ?? null,
importTimestamp,
domainIdBySlug
);
const createdAt =
this.normalizeDate(project.created ?? null, `project ${brainId}.created`, errors) ??
new Date();
const updatedAt =
this.normalizeDate(project.updated ?? null, `project ${brainId}.updated`, errors) ??
createdAt;
const startDate = this.normalizeDate(
project.created ?? null,
`project ${brainId}.startDate`,
errors
);
const endDate = this.normalizeDate(
project.target_date ?? null,
`project ${brainId}.target_date`,
errors
);
const metadata = this.asJsonValue({
source: "jarvis-brain",
brainId,
brainDomain: project.domain ?? null,
rawStatus: project.status ?? null,
rawPriority: project.priority ?? null,
progress: project.progress ?? null,
repo: project.repo ?? null,
branch: project.branch ?? null,
currentMilestone: project.current_milestone ?? null,
nextMilestone: project.next_milestone ?? null,
blocker: project.blocker ?? null,
owner: project.owner ?? null,
docsPath: project.docs_path ?? null,
targetDate: project.target_date ?? null,
notes: project.notes ?? null,
notesNonTechnical: project.notes_nontechnical ?? null,
parent: project.parent ?? null,
importedAt: importTimestamp,
});
await tx.project.create({
data: {
workspaceId,
name: project.name,
description: project.description ?? null,
status: mappedStatus.status,
startDate,
endDate,
creatorId: userId,
domainId,
metadata,
createdAt,
updatedAt,
},
});
imported += 1;
} catch (error) {
skipped += 1;
errors.push(
`project ${brainId || `index-${String(index)}`}: failed to import: ${this.getErrorMessage(error)}`
);
}
}
});
return {
imported,
skipped,
errors,
};
}
private async resolveProjectId(
tx: PrismaClient,
workspaceId: string,
projectBrainId: string | null,
projectIdByBrainId: Map<string, string | null>,
taskBrainId: string,
errors: string[]
): Promise<string | null> {
if (!projectBrainId) {
return null;
}
if (projectIdByBrainId.has(projectBrainId)) {
return projectIdByBrainId.get(projectBrainId) ?? null;
}
const existingProject = await tx.project.findFirst({
where: {
workspaceId,
metadata: {
path: ["brainId"],
equals: projectBrainId,
},
},
select: { id: true },
});
if (!existingProject) {
projectIdByBrainId.set(projectBrainId, null);
errors.push(`task ${taskBrainId}: referenced project "${projectBrainId}" not found`);
return null;
}
projectIdByBrainId.set(projectBrainId, existingProject.id);
return existingProject.id;
}
private async resolveDomainId(
tx: PrismaClient,
workspaceId: string,
rawDomain: string | null,
importTimestamp: string,
domainIdBySlug: Map<string, string>
): Promise<string | null> {
const domainSlug = this.normalizeDomain(rawDomain);
if (!domainSlug) {
return null;
}
const cachedId = domainIdBySlug.get(domainSlug);
if (cachedId) {
return cachedId;
}
const existingDomain = await tx.domain.findUnique({
where: {
workspaceId_slug: {
workspaceId,
slug: domainSlug,
},
},
select: { id: true },
});
if (existingDomain) {
domainIdBySlug.set(domainSlug, existingDomain.id);
return existingDomain.id;
}
const trimmedDomainName = rawDomain?.trim();
const domainName =
trimmedDomainName && trimmedDomainName.length > 0 ? trimmedDomainName : domainSlug;
const createdDomain = await tx.domain.create({
data: {
workspaceId,
slug: domainSlug,
name: domainName,
metadata: this.asJsonValue({
source: "jarvis-brain",
brainId: domainName,
sourceValues: [domainName],
importedAt: importTimestamp,
}),
},
select: { id: true },
});
domainIdBySlug.set(domainSlug, createdDomain.id);
return createdDomain.id;
}
private normalizeKey(value: string | null | undefined): string {
return value?.trim().toLowerCase() ?? "";
}
private mapTaskStatus(rawStatus: string | null): TaskStatusMapping {
const statusKey = this.normalizeKey(rawStatus);
switch (statusKey) {
case "done":
return { status: TaskStatus.COMPLETED, issue: null };
case "in-progress":
return { status: TaskStatus.IN_PROGRESS, issue: null };
case "backlog":
case "pending":
case "scheduled":
case "not-started":
case "planned":
return { status: TaskStatus.NOT_STARTED, issue: null };
case "blocked":
case "on-hold":
return { status: TaskStatus.PAUSED, issue: null };
case "cancelled":
return { status: TaskStatus.ARCHIVED, issue: null };
default:
return {
status: TaskStatus.NOT_STARTED,
issue: `Unknown task status "${rawStatus ?? "null"}" mapped to NOT_STARTED`,
};
}
}
private mapTaskPriority(rawPriority: string | null): TaskPriorityMapping {
const priorityKey = this.normalizeKey(rawPriority);
switch (priorityKey) {
case "critical":
case "high":
return { priority: TaskPriority.HIGH, issue: null };
case "medium":
return { priority: TaskPriority.MEDIUM, issue: null };
case "low":
return { priority: TaskPriority.LOW, issue: null };
default:
return {
priority: TaskPriority.MEDIUM,
issue: `Unknown task priority "${rawPriority ?? "null"}" mapped to MEDIUM`,
};
}
}
private mapProjectStatus(rawStatus: string | null): ProjectStatusMapping {
const statusKey = this.normalizeKey(rawStatus);
switch (statusKey) {
case "active":
case "in-progress":
return { status: ProjectStatus.ACTIVE, issue: null };
case "backlog":
case "planning":
return { status: ProjectStatus.PLANNING, issue: null };
case "paused":
case "blocked":
return { status: ProjectStatus.PAUSED, issue: null };
case "archived":
case "maintenance":
return { status: ProjectStatus.ARCHIVED, issue: null };
default:
return {
status: ProjectStatus.PLANNING,
issue: `Unknown project status "${rawStatus ?? "null"}" mapped to PLANNING`,
};
}
}
private normalizeDomain(rawDomain: string | null | undefined): string | null {
if (!rawDomain) {
return null;
}
const trimmed = rawDomain.trim();
if (trimmed.length === 0) {
return null;
}
const slug = trimmed
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug.length > 0 ? slug : null;
}
private normalizeDate(rawValue: string | null, context: string, errors: string[]): Date | null {
if (!rawValue) {
return null;
}
const trimmed = rawValue.trim();
if (trimmed.length === 0) {
return null;
}
const value = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? `${trimmed}T00:00:00.000Z` : trimmed;
const parsedDate = new Date(value);
if (Number.isNaN(parsedDate.getTime())) {
errors.push(`${context}: invalid date "${rawValue}"`);
return null;
}
return parsedDate;
}
private asJsonValue(value: Record<string, unknown>): Prisma.InputJsonValue {
return value as Prisma.InputJsonValue;
}
private getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
}

View File

@@ -1,6 +1,6 @@
import { IsOptional, IsEnum, IsString, IsInt, IsIn, Min, Max } from "class-validator";
import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator";
import { Type } from "class-transformer";
import { EntryStatus, Visibility } from "@prisma/client";
import { EntryStatus } from "@prisma/client";
/**
* DTO for querying knowledge entries (list endpoint)
@@ -10,28 +10,10 @@ 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" })

View File

@@ -48,10 +48,6 @@ export class KnowledgeService {
where.status = query.status;
}
if (query.visibility) {
where.visibility = query.visibility;
}
if (query.tag) {
where.tags = {
some: {
@@ -62,20 +58,6 @@ 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 });
@@ -89,7 +71,9 @@ export class KnowledgeService {
},
},
},
orderBy,
orderBy: {
updatedAt: "desc",
},
skip,
take: limit,
});

View File

@@ -1,38 +1,59 @@
import { FormalityLevel } from "@prisma/client";
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsInt,
IsUUID,
MinLength,
MaxLength,
Min,
Max,
} from "class-validator";
/**
* DTO for creating a new personality
* Field names match the frontend API contract from @mosaic/shared Personality type.
* DTO for creating a new personality/assistant configuration
*/
export class CreatePersonalityDto {
@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()
@MinLength(1)
@MaxLength(100)
name!: string; // unique identifier slug
@IsString()
@MinLength(1)
@MaxLength(200)
displayName!: string; // human-readable name
@IsOptional()
@IsString({ message: "description must be a string" })
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
@IsString()
@MaxLength(1000)
description?: 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;
@IsString()
@MinLength(10)
systemPrompt!: string;
@IsOptional()
@IsBoolean({ message: "isDefault must be a boolean" })
@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()
isDefault?: boolean;
@IsOptional()
@IsBoolean({ message: "isActive must be a boolean" })
isActive?: boolean;
@IsBoolean()
isEnabled?: boolean;
}

View File

@@ -1,3 +1,2 @@
export * from "./create-personality.dto";
export * from "./update-personality.dto";
export * from "./personality-query.dto";

View File

@@ -1,12 +0,0 @@
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;
}

View File

@@ -1,42 +1,62 @@
import { FormalityLevel } from "@prisma/client";
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsInt,
IsUUID,
MinLength,
MaxLength,
Min,
Max,
} from "class-validator";
/**
* DTO for updating an existing personality
* All fields are optional; only provided fields are updated.
* DTO for updating an existing personality/assistant configuration
*/
export class UpdatePersonalityDto {
@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;
@IsString()
@MinLength(1)
@MaxLength(100)
name?: string; // unique identifier slug
@IsOptional()
@IsString({ message: "description must be a string" })
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
@IsString()
@MinLength(1)
@MaxLength(200)
displayName?: string; // human-readable name
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string;
@IsOptional()
@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;
@IsString()
@MinLength(10)
systemPrompt?: string;
@IsOptional()
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
formalityLevel?: FormalityLevel;
@IsNumber()
@Min(0)
@Max(2)
temperature?: number; // null = use provider default
@IsOptional()
@IsString({ message: "systemPromptTemplate must be a string" })
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
systemPromptTemplate?: string;
@IsInt()
@Min(1)
maxTokens?: number; // null = use provider default
@IsOptional()
@IsBoolean({ message: "isDefault must be a boolean" })
@IsUUID("4")
llmProviderInstanceId?: string; // FK to LlmProviderInstance
@IsOptional()
@IsBoolean()
isDefault?: boolean;
@IsOptional()
@IsBoolean({ message: "isActive must be a boolean" })
isActive?: boolean;
@IsBoolean()
isEnabled?: boolean;
}

View File

@@ -1,24 +1,20 @@
import type { FormalityLevel } from "@prisma/client";
import type { Personality as PrismaPersonality } from "@prisma/client";
/**
* 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)
* Personality entity representing an assistant configuration
*/
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;
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;
}

View File

@@ -2,32 +2,36 @@ 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 type { CreatePersonalityDto } from "./dto/create-personality.dto";
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./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",
tone: "professional",
formalityLevel: FormalityLevel.FORMAL,
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
systemPrompt: "You are a professional assistant who helps with tasks.",
temperature: 0.7,
maxTokens: 2000,
llmProviderInstanceId: "provider-123",
isDefault: true,
isActive: true,
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
isEnabled: true,
createdAt: new Date(),
updatedAt: new Date(),
};
const mockRequest = {
user: { id: mockUserId },
workspaceId: mockWorkspaceId,
};
const mockPersonalitiesService = {
@@ -53,54 +57,24 @@ 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 success response with personalities list", async () => {
const mockList = [mockPersonality];
mockPersonalitiesService.findAll.mockResolvedValue(mockList);
it("should return all personalities", async () => {
const mockPersonalities = [mockPersonality];
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
const result = await controller.findAll(mockWorkspaceId, {});
const result = await controller.findAll(mockRequest);
expect(result).toEqual({ success: true, data: mockList });
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {});
});
it("should pass isActive query filter to service", async () => {
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
await controller.findAll(mockWorkspaceId, { isActive: true });
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true });
});
});
describe("findDefault", () => {
it("should return the default personality", async () => {
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
const result = await controller.findDefault(mockWorkspaceId);
expect(result).toEqual(mockPersonality);
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
expect(result).toEqual(mockPersonalities);
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
});
});
@@ -108,29 +82,54 @@ describe("PersonalitiesController", () => {
it("should return a personality by id", async () => {
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
const result = await controller.findOne(mockWorkspaceId, mockPersonalityId);
const result = await controller.findOne(mockRequest, mockPersonalityId);
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");
});
});
describe("findDefault", () => {
it("should return the default personality", async () => {
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
const result = await controller.findDefault(mockRequest);
expect(result).toEqual(mockPersonality);
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
});
});
describe("create", () => {
it("should create a new personality", async () => {
const createDto: CreatePersonalityDto = {
name: "casual-helper",
displayName: "Casual Helper",
description: "A casual helper",
tone: "casual",
formalityLevel: FormalityLevel.CASUAL,
systemPromptTemplate: "You are a casual assistant.",
systemPrompt: "You are a casual assistant.",
temperature: 0.8,
maxTokens: 1500,
};
const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false };
mockPersonalitiesService.create.mockResolvedValue(created);
mockPersonalitiesService.create.mockResolvedValue({
...mockPersonality,
...createDto,
});
const result = await controller.create(mockWorkspaceId, createDto);
const result = await controller.create(mockRequest, createDto);
expect(result).toMatchObject({ name: createDto.name, tone: createDto.tone });
expect(result).toMatchObject(createDto);
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
});
});
@@ -139,13 +138,15 @@ describe("PersonalitiesController", () => {
it("should update a personality", async () => {
const updateDto: UpdatePersonalityDto = {
description: "Updated description",
tone: "enthusiastic",
temperature: 0.9,
};
const updated = { ...mockPersonality, ...updateDto };
mockPersonalitiesService.update.mockResolvedValue(updated);
mockPersonalitiesService.update.mockResolvedValue({
...mockPersonality,
...updateDto,
});
const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto);
const result = await controller.update(mockRequest, mockPersonalityId, updateDto);
expect(result).toMatchObject(updateDto);
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
@@ -156,7 +157,7 @@ describe("PersonalitiesController", () => {
it("should delete a personality", async () => {
mockPersonalitiesService.delete.mockResolvedValue(undefined);
await controller.delete(mockWorkspaceId, mockPersonalityId);
await controller.delete(mockRequest, mockPersonalityId);
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
});
@@ -164,10 +165,12 @@ describe("PersonalitiesController", () => {
describe("setDefault", () => {
it("should set a personality as default", async () => {
const updated = { ...mockPersonality, isDefault: true };
mockPersonalitiesService.setDefault.mockResolvedValue(updated);
mockPersonalitiesService.setDefault.mockResolvedValue({
...mockPersonality,
isDefault: true,
});
const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId);
const result = await controller.setDefault(mockRequest, mockPersonalityId);
expect(result).toMatchObject({ isDefault: true });
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);

View File

@@ -6,122 +6,105 @@ 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 } 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";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import { Personality } from "./entities/personality.entity";
interface AuthenticatedRequest {
user: { id: string };
workspaceId: string;
}
/**
* 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 for managing personality/assistant configurations
*/
@Controller("personalities")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
@Controller("personality")
@UseGuards(AuthGuard)
export class PersonalitiesController {
constructor(private readonly personalitiesService: PersonalitiesService) {}
/**
* GET /api/personalities
* List all personalities for the workspace.
* Supports ?isActive=true|false filter.
* List all personalities for the workspace
*/
@Get()
@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 };
async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> {
return this.personalitiesService.findAll(req.workspaceId);
}
/**
* GET /api/personalities/default
* Get the default personality for the workspace.
* Must be declared before :id to avoid route conflicts.
* Get the default personality for the workspace
*/
@Get("default")
@RequirePermission(Permission.WORKSPACE_ANY)
async findDefault(@Workspace() workspaceId: string): Promise<PersonalityResponse> {
return this.personalitiesService.findDefault(workspaceId);
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
return this.personalitiesService.findDefault(req.workspaceId);
}
/**
* GET /api/personalities/:id
* Get a single personality by ID.
* 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(":id")
@RequirePermission(Permission.WORKSPACE_ANY)
async findOne(
@Workspace() workspaceId: string,
@Param("id") id: string
): Promise<PersonalityResponse> {
return this.personalitiesService.findOne(workspaceId, id);
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
return this.personalitiesService.findOne(req.workspaceId, id);
}
/**
* POST /api/personalities
* Create a new personality.
* Create a new personality
*/
@Post()
@HttpCode(HttpStatus.CREATED)
@RequirePermission(Permission.WORKSPACE_MEMBER)
async create(
@Workspace() workspaceId: string,
@Req() req: AuthenticatedRequest,
@Body() dto: CreatePersonalityDto
): Promise<PersonalityResponse> {
return this.personalitiesService.create(workspaceId, dto);
): Promise<Personality> {
return this.personalitiesService.create(req.workspaceId, dto);
}
/**
* PATCH /api/personalities/:id
* Update an existing personality.
* Update a personality
*/
@Patch(":id")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async update(
@Workspace() workspaceId: string,
@Req() req: AuthenticatedRequest,
@Param("id") id: string,
@Body() dto: UpdatePersonalityDto
): Promise<PersonalityResponse> {
return this.personalitiesService.update(workspaceId, id, dto);
): Promise<Personality> {
return this.personalitiesService.update(req.workspaceId, id, dto);
}
/**
* DELETE /api/personalities/:id
* Delete a personality.
* Delete a personality
*/
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WORKSPACE_MEMBER)
async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise<void> {
return this.personalitiesService.delete(workspaceId, id);
async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
return this.personalitiesService.delete(req.workspaceId, id);
}
/**
* POST /api/personalities/:id/set-default
* Convenience endpoint to set a personality as the default.
* Set a personality as the default
*/
@Post(":id/set-default")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async setDefault(
@Workspace() workspaceId: string,
@Req() req: AuthenticatedRequest,
@Param("id") id: string
): Promise<PersonalityResponse> {
return this.personalitiesService.setDefault(workspaceId, id);
): Promise<Personality> {
return this.personalitiesService.setDefault(req.workspaceId, id);
}
}

View File

@@ -2,10 +2,8 @@ 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 type { CreatePersonalityDto } from "./dto/create-personality.dto";
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import { NotFoundException, ConflictException } from "@nestjs/common";
import { FormalityLevel } from "@prisma/client";
describe("PersonalitiesService", () => {
let service: PersonalitiesService;
@@ -13,39 +11,22 @@ describe("PersonalitiesService", () => {
const mockWorkspaceId = "workspace-123";
const mockPersonalityId = "personality-123";
const mockProviderId = "provider-123";
/** Raw Prisma record shape (uses Prisma field names) */
const mockPrismaRecord = {
const mockPersonality = {
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: "provider-123",
llmProviderInstanceId: mockProviderId,
isDefault: true,
isEnabled: true,
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"),
createdAt: new Date(),
updatedAt: new Date(),
};
const mockPrismaService = {
@@ -56,7 +37,9 @@ describe("PersonalitiesService", () => {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
$transaction: vi.fn((callback) => callback(mockPrismaService)),
};
beforeEach(async () => {
@@ -73,54 +56,44 @@ 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",
tone: "casual",
formalityLevel: FormalityLevel.CASUAL,
systemPromptTemplate: "You are a casual assistant.",
isDefault: false,
isActive: true,
systemPrompt: "You are a casual assistant.",
temperature: 0.8,
maxTokens: 1500,
llmProviderInstanceId: mockProviderId,
};
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 () => {
it("should create a new personality", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
mockPrismaService.personality.create.mockResolvedValue(createdRecord);
mockPrismaService.personality.create.mockResolvedValue({
...mockPersonality,
...createDto,
id: "new-personality-id",
isDefault: false,
isEnabled: true,
});
const result = await service.create(mockWorkspaceId, 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(result).toMatchObject(createDto);
expect(prisma.personality.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspaceId,
name: createDto.name,
displayName: createDto.name,
displayName: createDto.displayName,
description: createDto.description ?? null,
tone: createDto.tone,
formalityLevel: createDto.formalityLevel,
systemPrompt: createDto.systemPromptTemplate,
systemPrompt: createDto.systemPrompt,
temperature: createDto.temperature ?? null,
maxTokens: createDto.maxTokens ?? null,
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
isDefault: false,
isEnabled: true,
},
@@ -128,73 +101,68 @@ describe("PersonalitiesService", () => {
});
it("should throw ConflictException when name already exists", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
});
it("should unset other defaults when creating a new default personality", async () => {
const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true };
const otherDefault = { ...mockPrismaRecord, id: "other-id" };
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
mockPrismaService.personality.findFirst
.mockResolvedValueOnce(null) // name conflict check
.mockResolvedValueOnce(otherDefault); // existing default lookup
mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false });
.mockResolvedValueOnce(null) // No name conflict
.mockResolvedValueOnce(mockPersonality); // Existing default
mockPrismaService.personality.update.mockResolvedValue({
...mockPersonality,
isDefault: false,
});
mockPrismaService.personality.create.mockResolvedValue({
...createdRecord,
isDefault: true,
...mockPersonality,
...createDefaultDto,
});
await service.create(mockWorkspaceId, createDefaultDto);
expect(prisma.personality.update).toHaveBeenCalledWith({
where: { id: "other-id" },
where: { id: mockPersonalityId },
data: { isDefault: false },
});
});
});
describe("findAll", () => {
it("should return mapped response list for a workspace", async () => {
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
it("should return all personalities for a workspace", async () => {
const mockPersonalities = [mockPersonality];
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
const result = await service.findAll(mockWorkspaceId);
expect(result).toHaveLength(1);
expect(result[0]).toEqual(mockResponse);
expect(result).toEqual(mockPersonalities);
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 mapped personality response by id", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
it("should return a personality by id", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
expect(result).toEqual(mockResponse);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { id: mockPersonalityId, workspaceId: mockWorkspaceId },
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
where: {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
},
});
});
it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException
@@ -203,14 +171,17 @@ describe("PersonalitiesService", () => {
});
describe("findByName", () => {
it("should return a mapped personality response by name", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
it("should return a personality by name", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
expect(result).toEqual(mockResponse);
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, name: "professional-assistant" },
where: {
workspaceId: mockWorkspaceId,
name: "professional-assistant",
},
});
});
@@ -225,11 +196,11 @@ describe("PersonalitiesService", () => {
describe("findDefault", () => {
it("should return the default personality", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
const result = await service.findDefault(mockWorkspaceId);
expect(result).toEqual(mockResponse);
expect(result).toEqual(mockPersonality);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
});
@@ -245,45 +216,41 @@ describe("PersonalitiesService", () => {
describe("update", () => {
const updateDto: UpdatePersonalityDto = {
description: "Updated description",
tone: "formal",
isActive: false,
temperature: 0.9,
};
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);
it("should update a personality", async () => {
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue(null);
mockPrismaService.personality.update.mockResolvedValue({
...mockPersonality,
...updateDto,
});
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
expect(result.description).toBe(updateDto.description);
expect(result.tone).toBe(updateDto.tone);
expect(result.isActive).toBe(false);
expect(result).toMatchObject(updateDto);
expect(prisma.personality.update).toHaveBeenCalledWith({
where: { id: mockPersonalityId },
data: updateDto,
});
});
it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
NotFoundException
);
});
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
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",
});
await expect(
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
@@ -291,16 +258,14 @@ describe("PersonalitiesService", () => {
});
it("should unset other defaults when setting as default", async () => {
const updateDefaultDto: UpdatePersonalityDto = { isDefault: true };
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
const updateDefaultDto = { isDefault: true };
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
mockPrismaService.personality.findFirst
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
mockPrismaService.personality.update
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
.mockResolvedValueOnce(updatedRecord);
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
.mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
@@ -308,12 +273,16 @@ 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.findFirst.mockResolvedValue(mockPrismaRecord);
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.delete.mockResolvedValue(undefined);
await service.delete(mockWorkspaceId, mockPersonalityId);
@@ -324,7 +293,7 @@ describe("PersonalitiesService", () => {
});
it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException
@@ -334,27 +303,30 @@ describe("PersonalitiesService", () => {
describe("setDefault", () => {
it("should set a personality as default", async () => {
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
const updatedPersonality = { ...mockPersonality, isDefault: true };
mockPrismaService.personality.findFirst
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
mockPrismaService.personality.update
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
.mockResolvedValueOnce(updatedRecord);
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
.mockResolvedValueOnce(updatedPersonality); // Set new default
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
expect(result.isDefault).toBe(true);
expect(prisma.personality.update).toHaveBeenCalledWith({
expect(result).toMatchObject({ isDefault: true });
expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
where: { id: "other-id" },
data: { isDefault: false },
});
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
where: { id: mockPersonalityId },
data: { isDefault: true },
});
});
it("should throw NotFoundException when personality not found", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
mockPrismaService.personality.findUnique.mockResolvedValue(null);
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException

View File

@@ -1,17 +1,10 @@
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
import type { FormalityLevel, Personality } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
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";
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import { Personality } from "./entities/personality.entity";
/**
* Service for managing personality/assistant configurations.
*
* Field mapping:
* Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate`
* Prisma `isEnabled` <-> API/frontend `isActive`
* Service for managing personality/assistant configurations
*/
@Injectable()
export class PersonalitiesService {
@@ -19,30 +12,11 @@ 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<PersonalityResponse> {
// Check for duplicate name within workspace
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
// Check for duplicate name
const existing = await this.prisma.personality.findFirst({
where: { workspaceId, name: dto.name },
});
@@ -51,7 +25,7 @@ export class PersonalitiesService {
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
}
// If creating as default, unset other defaults first
// If creating a default personality, unset other defaults
if (dto.isDefault) {
await this.unsetOtherDefaults(workspaceId);
}
@@ -60,43 +34,36 @@ export class PersonalitiesService {
data: {
workspaceId,
name: dto.name,
displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
displayName: dto.displayName,
description: dto.description ?? null,
tone: dto.tone,
formalityLevel: dto.formalityLevel,
systemPrompt: dto.systemPromptTemplate,
systemPrompt: dto.systemPrompt,
temperature: dto.temperature ?? null,
maxTokens: dto.maxTokens ?? null,
llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
isDefault: dto.isDefault ?? false,
isEnabled: dto.isActive ?? true,
isEnabled: dto.isEnabled ?? true,
},
});
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
return this.toResponse(personality);
return personality;
}
/**
* Find all personalities for a workspace with optional active filter
* Find all personalities for a workspace
*/
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,
async findAll(workspaceId: string): Promise<Personality[]> {
return this.prisma.personality.findMany({
where: { workspaceId },
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<PersonalityResponse> {
const personality = await this.prisma.personality.findFirst({
async findOne(workspaceId: string, id: string): Promise<Personality> {
const personality = await this.prisma.personality.findUnique({
where: { id, workspaceId },
});
@@ -104,13 +71,13 @@ export class PersonalitiesService {
throw new NotFoundException(`Personality with ID ${id} not found`);
}
return this.toResponse(personality);
return personality;
}
/**
* Find a personality by name slug
* Find a personality by name
*/
async findByName(workspaceId: string, name: string): Promise<PersonalityResponse> {
async findByName(workspaceId: string, name: string): Promise<Personality> {
const personality = await this.prisma.personality.findFirst({
where: { workspaceId, name },
});
@@ -119,13 +86,13 @@ export class PersonalitiesService {
throw new NotFoundException(`Personality with name "${name}" not found`);
}
return this.toResponse(personality);
return personality;
}
/**
* Find the default (and enabled) personality for a workspace
* Find the default personality for a workspace
*/
async findDefault(workspaceId: string): Promise<PersonalityResponse> {
async findDefault(workspaceId: string): Promise<Personality> {
const personality = await this.prisma.personality.findFirst({
where: { workspaceId, isDefault: true, isEnabled: true },
});
@@ -134,18 +101,14 @@ export class PersonalitiesService {
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
}
return this.toResponse(personality);
return personality;
}
/**
* Update an existing personality
*/
async update(
workspaceId: string,
id: string,
dto: UpdatePersonalityDto
): Promise<PersonalityResponse> {
// Verify existence
async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise<Personality> {
// Check existence
await this.findOne(workspaceId, id);
// Check for duplicate name if updating name
@@ -164,43 +127,20 @@ 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: updateData,
data: dto,
});
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
return this.toResponse(personality);
return personality;
}
/**
* Delete a personality
*/
async delete(workspaceId: string, id: string): Promise<void> {
// Verify existence
// Check existence
await this.findOne(workspaceId, id);
await this.prisma.personality.delete({
@@ -211,22 +151,23 @@ export class PersonalitiesService {
}
/**
* Set a personality as the default (convenience endpoint)
* Set a personality as the default
*/
async setDefault(workspaceId: string, id: string): Promise<PersonalityResponse> {
// Verify existence
async setDefault(workspaceId: string, id: string): Promise<Personality> {
// Check 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 this.toResponse(personality);
return personality;
}
/**
@@ -237,7 +178,7 @@ export class PersonalitiesService {
where: {
workspaceId,
isDefault: true,
...(excludeId !== undefined && { id: { not: excludeId } }),
...(excludeId && { id: { not: excludeId } }),
},
});

View File

@@ -140,11 +140,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
workspaceId: string,
client: PrismaClient = this
): Promise<void> {
// 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)`;
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
}
/**
@@ -154,8 +151,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`SELECT set_config('app.current_user_id', '', true)`;
await client.$executeRaw`SELECT set_config('app.current_workspace_id', '', true)`;
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
}
/**

View File

@@ -1,13 +0,0 @@
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;
}

View File

@@ -1,11 +0,0 @@
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;
}

View File

@@ -1,150 +0,0 @@
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);
});
});
});

View File

@@ -1,51 +0,0 @@
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);
}
}

View File

@@ -1,13 +0,0 @@
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 {}

View File

@@ -1,286 +0,0 @@
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);
});
});
});

View File

@@ -1,130 +0,0 @@
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`);
}
}
}

View File

@@ -46,7 +46,7 @@ describe("TerminalService", () => {
let service: TerminalService;
let mockSocket: Socket;
beforeEach(async () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset mock implementations
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
@@ -54,8 +54,6 @@ describe("TerminalService", () => {
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
);
service = new TerminalService();
// Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock)
await service.onModuleInit();
mockSocket = createMockSocket();
});

View File

@@ -13,19 +13,11 @@
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
*/
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
import type { IPty } from "node-pty";
import { Injectable, Logger } from "@nestjs/common";
import * as pty from "node-pty";
import type { Socket } from "socket.io";
import { randomUUID } from "node:crypto";
// Lazy-loaded in onModuleInit via dynamic import() to prevent crash
// if the native binary is missing. node-pty requires a compiled .node
// binary which may not be available in all Docker environments.
interface NodePtyModule {
spawn: (file: string, args: string[], options: Record<string, unknown>) => IPty;
}
let pty: NodePtyModule | null = null;
/** Maximum concurrent PTY sessions per workspace */
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
@@ -39,7 +31,7 @@ const DEFAULT_ROWS = 24;
export interface TerminalSession {
sessionId: string;
workspaceId: string;
pty: IPty;
pty: pty.IPty;
name?: string;
createdAt: Date;
}
@@ -61,7 +53,7 @@ export interface SessionCreatedResult {
}
@Injectable()
export class TerminalService implements OnModuleInit {
export class TerminalService {
private readonly logger = new Logger(TerminalService.name);
/**
@@ -74,30 +66,13 @@ export class TerminalService implements OnModuleInit {
*/
private readonly workspaceSessions = new Map<string, Set<string>>();
async onModuleInit(): Promise<void> {
if (!pty) {
try {
pty = await import("node-pty");
this.logger.log("node-pty loaded successfully — terminal sessions available");
} catch {
this.logger.warn(
"node-pty native module not available — terminal sessions will be disabled. " +
"Install build tools (python3, make, g++) and rebuild node-pty to enable."
);
}
}
}
/**
* Create a new PTY session for the given workspace and socket.
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
*
* @throws Error if workspace session limit is exceeded or node-pty is unavailable
* @throws Error if workspace session limit is exceeded
*/
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
if (!pty) {
throw new Error("Terminal sessions are unavailable: node-pty native module failed to load");
}
const { workspaceId, name, cwd, socketId } = options;
const cols = options.cols ?? DEFAULT_COLS;
const rows = options.rows ?? DEFAULT_ROWS;

View File

@@ -1,112 +0,0 @@
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();
});
});
});

View File

@@ -2,7 +2,6 @@ import {
Controller,
Get,
Put,
Patch,
Body,
UseGuards,
Request,
@@ -39,7 +38,7 @@ export class PreferencesController {
/**
* PUT /api/users/me/preferences
* Full replace of current user's preferences
* Update current user's preferences
*/
@Put()
async updatePreferences(
@@ -54,22 +53,4 @@ 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);
}
}

View File

@@ -1,141 +0,0 @@
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 });
});
});
});

View File

@@ -7,7 +7,6 @@ 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 {
@@ -78,7 +77,7 @@ interface StepOutputData {
*/
@WSGateway({
cors: {
origin: getTrustedOrigins(),
origin: process.env.WEB_URL ?? "http://localhost:3000",
credentials: true,
},
})
@@ -168,36 +167,17 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
}
/**
* @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
*
* @description Extract authentication token from Socket.IO handshake
* @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 for non-browser clients)
// Check handshake.auth.token (preferred method)
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) {
@@ -217,45 +197,6 @@ 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.

View File

@@ -1,14 +1,22 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
import {
Controller,
Get,
Post,
Body,
Param,
UseGuards,
Request,
UnauthorizedException,
} 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 { RequestWithWorkspace } from "../common/types/user.types";
import type { AuthenticatedRequest } from "../common/types/user.types";
/**
* Controller for widget definition and data endpoints
* All endpoints require authentication; data endpoints also require workspace context
* All endpoints require authentication
*/
@Controller("widgets")
@UseGuards(AuthGuard)
@@ -43,9 +51,12 @@ export class WidgetsController {
* Get stat card widget data
*/
@Post("data/stat-card")
@UseGuards(WorkspaceGuard)
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
return this.widgetDataService.getStatCardData(req.workspace.id, query);
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);
}
/**
@@ -53,9 +64,12 @@ export class WidgetsController {
* Get chart widget data
*/
@Post("data/chart")
@UseGuards(WorkspaceGuard)
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
return this.widgetDataService.getChartData(req.workspace.id, query);
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);
}
/**
@@ -63,9 +77,12 @@ export class WidgetsController {
* Get list widget data
*/
@Post("data/list")
@UseGuards(WorkspaceGuard)
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
return this.widgetDataService.getListData(req.workspace.id, query);
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);
}
/**
@@ -73,12 +90,15 @@ export class WidgetsController {
* Get calendar preview widget data
*/
@Post("data/calendar-preview")
@UseGuards(WorkspaceGuard)
async getCalendarPreviewData(
@Request() req: RequestWithWorkspace,
@Request() req: AuthenticatedRequest,
@Body() query: CalendarPreviewQueryDto
) {
return this.widgetDataService.getCalendarPreviewData(req.workspace.id, query);
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
if (!workspaceId) {
throw new UnauthorizedException("Workspace ID required");
}
return this.widgetDataService.getCalendarPreviewData(workspaceId, query);
}
/**
@@ -86,9 +106,12 @@ export class WidgetsController {
* Get active projects widget data
*/
@Post("data/active-projects")
@UseGuards(WorkspaceGuard)
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
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);
}
/**
@@ -96,8 +119,11 @@ export class WidgetsController {
* Get agent chains widget data (active agent sessions)
*/
@Post("data/agent-chains")
@UseGuards(WorkspaceGuard)
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
return this.widgetDataService.getAgentChainsData(req.workspace.id);
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);
}
}

View File

@@ -1,13 +0,0 @@
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;
}

View File

@@ -1,3 +0,0 @@
export { AddMemberDto } from "./add-member.dto";
export { UpdateMemberRoleDto } from "./update-member-role.dto";
export { WorkspaceResponseDto } from "./workspace-response.dto";

View File

@@ -1,10 +0,0 @@
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;
}

View File

@@ -1,12 +0,0 @@
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;
}

View File

@@ -1,3 +0,0 @@
export { WorkspacesModule } from "./workspaces.module";
export { WorkspacesService } from "./workspaces.service";
export { WorkspacesController } from "./workspaces.controller";

View File

@@ -1,149 +0,0 @@
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);
});
});
});

View File

@@ -1,85 +0,0 @@
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);
}
}

View File

@@ -1,13 +0,0 @@
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 {}

View File

@@ -1,516 +0,0 @@
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);
});
});
});

View File

@@ -1,345 +0,0 @@
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";
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaic/orchestrator",
"version": "0.0.20",
"version": "0.0.6",
"private": true,
"scripts": {
"dev": "nest start --watch",

View File

@@ -1,6 +1,6 @@
{
"name": "@mosaic/web",
"version": "0.0.20",
"version": "0.0.1",
"private": true,
"scripts": {
"build": "next build",
@@ -33,9 +33,6 @@
"@tiptap/react": "^3.20.0",
"@tiptap/starter-kit": "^3.20.0",
"@types/dompurify": "^3.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/react": "^12.5.3",
"better-auth": "^1.4.17",
"date-fns": "^4.1.0",

View File

@@ -326,7 +326,7 @@ function LoginPageContent(): ReactElement {
</div>
<div className="mt-6 flex justify-center">
<AuthStatusPill label="Mosaic v0.0.20" tone="neutral" />
<AuthStatusPill label="Mosaic v0.1" tone="neutral" />
</div>
</AuthCard>
</AuthShell>

View File

@@ -103,7 +103,7 @@ export default function ProfilePage(): ReactElement {
setPrefsError(null);
try {
const data = await apiGet<UserPreferences>("/api/users/me/preferences");
const data = await apiGet<UserPreferences>("/users/me/preferences");
setPreferences(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Could not load preferences";
@@ -265,8 +265,23 @@ export default function ProfilePage(): ReactElement {
</p>
)}
{/* Workspace role badge — placeholder until workspace context API
provides role data via GET /api/workspaces */}
{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>
)}
</div>
</div>
</div>

View File

@@ -240,7 +240,7 @@ export default function AppearanceSettingsPage(): ReactElement {
setLocalTheme(themeId);
setSaving(true);
try {
await apiPatch("/api/users/me/preferences", { theme: themeId });
await apiPatch("/users/me/preferences", { theme: themeId });
} catch {
// Theme is still applied locally even if API save fails
} finally {

View File

@@ -14,7 +14,6 @@ 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" },
@@ -40,17 +39,17 @@ export default function CredentialAuditPage(): React.ReactElement {
const [filters, setFilters] = useState<FilterState>({});
const [hasFilters, setHasFilters] = useState(false);
const workspaceId = useWorkspaceId();
// TODO: Get workspace ID from context/auth
const workspaceId = "default-workspace-id"; // Placeholder
useEffect(() => {
if (!workspaceId) return;
void loadLogs(workspaceId);
}, [workspaceId, page, filters]);
void loadLogs();
}, [page, filters]);
async function loadLogs(wsId: string): Promise<void> {
async function loadLogs(): Promise<void> {
try {
setIsLoading(true);
const response = await fetchCredentialAuditLog(wsId, {
const response = await fetchCredentialAuditLog(workspaceId, {
...filters,
page,
limit,

File diff suppressed because it is too large Load Diff

View File

@@ -1,383 +1,23 @@
"use client";
import { useState, useEffect, type SyntheticEvent } from "react";
import type { ReactElement } from "react";
import { useState, useEffect } from "react";
import type { Domain } from "@mosaic/shared";
import { DomainList } from "@/components/domains/DomainList";
import { fetchDomains, createDomain, deleteDomain } from "@/lib/api/domains";
import type { CreateDomainDto } from "@/lib/api/domains";
import { useWorkspaceId } from "@/lib/hooks";
import { fetchDomains, deleteDomain } from "@/lib/api/domains";
/* ---------------------------------------------------------------------------
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();
export default function DomainsPage(): React.ReactElement {
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(undefined, workspaceId ?? undefined);
const response = await fetchDomains();
setDomains(response.data);
setError(null);
} catch (err) {
@@ -387,8 +27,9 @@ export default function DomainsPage(): 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> {
@@ -397,26 +38,13 @@ export default function DomainsPage(): ReactElement {
}
try {
await deleteDomain(domain.id, workspaceId ?? undefined);
await deleteDomain(domain.id);
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">
@@ -432,7 +60,7 @@ export default function DomainsPage(): ReactElement {
<button
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
onClick={() => {
setCreateOpen(true);
console.log("TODO: Open create modal");
}}
>
Create Domain
@@ -445,13 +73,6 @@ export default function DomainsPage(): ReactElement {
onEdit={handleEdit}
onDelete={handleDelete}
/>
<CreateDomainDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSubmit={handleCreate}
isSubmitting={isCreating}
/>
</div>
);
}

View File

@@ -1,10 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import type { ReactElement, ReactNode } from "react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
import Link from "next/link";
interface CategoryConfig {
@@ -14,7 +11,6 @@ interface CategoryConfig {
accent: string;
iconBg: string;
icon: ReactNode;
adminOnly?: boolean;
}
interface SettingsCategoryCardProps {
@@ -200,57 +196,6 @@ const categories: CategoryConfig[] = [
</svg>
),
},
{
title: "Users",
description: "Invite, manage roles, and deactivate users across your workspaces.",
href: "/settings/users",
adminOnly: true,
accent: "var(--ms-green-400)",
iconBg: "rgba(34, 197, 94, 0.12)",
icon: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="8" cy="7" r="2.5" />
<circle cx="13.5" cy="8.5" r="2" />
<path d="M3.5 16c0-2.5 2-4.5 4.5-4.5s4.5 2 4.5 4.5" />
<path d="M12 13.8c.5-.8 1.4-1.3 2.5-1.3 1.7 0 3 1.3 3 3" />
</svg>
),
},
{
title: "Teams",
description: "Create and manage teams within your active workspace.",
href: "/settings/teams",
accent: "var(--ms-blue-400)",
iconBg: "rgba(47, 128, 255, 0.12)",
icon: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="7" cy="7" r="2.25" />
<circle cx="13" cy="7" r="2.25" />
<path d="M3 15c0-2.2 1.8-4 4-4s4 1.8 4 4" />
<path d="M9 15c0-2.2 1.8-4 4-4s4 1.8 4 4" />
</svg>
),
},
{
title: "Workspaces",
description:
@@ -282,30 +227,7 @@ const categories: CategoryConfig[] = [
},
];
const ADMIN_ROLES: WorkspaceMemberRole[] = [WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN];
export default function SettingsPage(): ReactElement {
const { user } = useAuth();
const [isAdmin, setIsAdmin] = useState<boolean>(false);
const checkRole = useCallback(async (): Promise<void> => {
if (user === null) return;
try {
const workspaces = await fetchUserWorkspaces();
const hasAdminRole = workspaces.some((ws) => ADMIN_ROLES.includes(ws.role));
setIsAdmin(hasAdminRole);
} catch {
// Fail open — show all items if we can't determine role
setIsAdmin(true);
}
}, [user]);
useEffect(() => {
void checkRole();
}, [checkRole]);
const visibleCategories = categories.filter((c) => c.adminOnly !== true || isAdmin);
return (
<div className="max-w-6xl mx-auto p-6">
{/* Page header */}
@@ -333,7 +255,7 @@ export default function SettingsPage(): ReactElement {
{/* Category grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{visibleCategories.map((category) => (
{categories.map((category) => (
<SettingsCategoryCard key={category.href} category={category} />
))}
</div>

View File

@@ -1,76 +0,0 @@
import type { ReactElement, ReactNode } from "react";
import type { TeamRecord } from "@/lib/api/teams";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchTeams } from "@/lib/api/teams";
import TeamsSettingsPage from "./page";
vi.mock("next/link", () => ({
default: function LinkMock({
children,
href,
}: {
children: ReactNode;
href: string;
}): ReactElement {
return <a href={href}>{children}</a>;
},
}));
vi.mock("@/lib/api/teams", () => ({
fetchTeams: vi.fn(),
createTeam: vi.fn(),
}));
const fetchTeamsMock = vi.mocked(fetchTeams);
const baseTeam: TeamRecord = {
id: "team-1",
workspaceId: "workspace-1",
name: "Platform Team",
description: "Owns platform services",
metadata: {},
createdAt: "2026-02-01T00:00:00.000Z",
updatedAt: "2026-02-01T00:00:00.000Z",
_count: {
members: 3,
},
};
describe("TeamsSettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("loads and renders teams from the API", async () => {
fetchTeamsMock.mockResolvedValue([baseTeam]);
render(<TeamsSettingsPage />);
expect(screen.getByText("Loading teams...")).toBeInTheDocument();
expect(await screen.findByText("Your Teams (1)")).toBeInTheDocument();
expect(screen.getByText("Platform Team")).toBeInTheDocument();
expect(fetchTeamsMock).toHaveBeenCalledTimes(1);
});
it("shows empty state when the API returns no teams", async () => {
fetchTeamsMock.mockResolvedValue([]);
render(<TeamsSettingsPage />);
expect(await screen.findByText("Your Teams (0)")).toBeInTheDocument();
expect(screen.getByText("No teams yet")).toBeInTheDocument();
});
it("shows error state and does not show empty state", async () => {
fetchTeamsMock.mockRejectedValue(new Error("Unable to load teams"));
render(<TeamsSettingsPage />);
expect(await screen.findByText("Unable to load teams")).toBeInTheDocument();
expect(screen.queryByText("No teams yet")).not.toBeInTheDocument();
});
});

View File

@@ -1,244 +0,0 @@
"use client";
import type { ReactElement, SyntheticEvent } from "react";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { createTeam, fetchTeams, type CreateTeamDto, type TeamRecord } from "@/lib/api/teams";
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) {
return error.message;
}
return fallback;
}
export default function TeamsSettingsPage(): ReactElement {
const [teams, setTeams] = useState<TeamRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [newTeamName, setNewTeamName] = useState("");
const [newTeamDescription, setNewTeamDescription] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const loadTeams = useCallback(async (): Promise<void> => {
setIsLoading(true);
try {
const data = await fetchTeams();
setTeams(data);
setLoadError(null);
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load teams"));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadTeams();
}, [loadTeams]);
const handleCreateTeam = async (e: SyntheticEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
const teamName = newTeamName.trim();
if (!teamName) return;
setIsCreating(true);
setCreateError(null);
try {
const description = newTeamDescription.trim();
const dto: CreateTeamDto = { name: teamName };
if (description.length > 0) {
dto.description = description;
}
await createTeam(dto);
setNewTeamName("");
setNewTeamDescription("");
setIsCreateDialogOpen(false);
await loadTeams();
} catch (error) {
setCreateError(getErrorMessage(error, "Failed to create team"));
} finally {
setIsCreating(false);
}
};
return (
<main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-3xl font-bold text-gray-900">Teams</h1>
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
{"<-"} Back to Settings
</Link>
</div>
<p className="text-gray-600">Manage teams in your active workspace</p>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">Create New Team</h2>
<p className="text-sm text-gray-600 mt-1">
Add a team to organize members and permissions.
</p>
</div>
<button
type="button"
onClick={() => {
setCreateError(null);
setIsCreateDialogOpen(true);
}}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"
>
Create Team
</button>
</div>
</div>
{isCreateDialogOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4"
role="dialog"
>
<div className="w-full max-w-lg rounded-lg border border-gray-200 bg-white p-6 shadow-xl">
<h3 className="text-lg font-semibold text-gray-900">Create New Team</h3>
<p className="mt-1 text-sm text-gray-600">
Enter a team name and optional description.
</p>
<form onSubmit={handleCreateTeam} className="mt-4 space-y-4">
<div>
<label htmlFor="team-name" className="mb-1 block text-sm font-medium text-gray-700">
Team Name
</label>
<input
id="team-name"
type="text"
value={newTeamName}
onChange={(e) => {
setNewTeamName(e.target.value);
}}
placeholder="Enter team name..."
disabled={isCreating}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
autoFocus
/>
</div>
<div>
<label
htmlFor="team-description"
className="mb-1 block text-sm font-medium text-gray-700"
>
Description (optional)
</label>
<textarea
id="team-description"
value={newTeamDescription}
onChange={(e) => {
setNewTeamDescription(e.target.value);
}}
placeholder="Describe this team's purpose..."
disabled={isCreating}
rows={3}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
</div>
{createError !== null && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{createError}
</div>
)}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => {
if (!isCreating) {
setIsCreateDialogOpen(false);
}
}}
disabled={isCreating}
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
type="submit"
disabled={isCreating || !newTeamName.trim()}
className="px-5 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{isCreating ? "Creating..." : "Create Team"}
</button>
</div>
</form>
</div>
</div>
)}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
Your Teams ({isLoading ? "..." : teams.length})
</h2>
{loadError !== null ? (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{loadError}
</div>
) : isLoading ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center text-gray-600">
Loading teams...
</div>
) : teams.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5V8H2v12h5m10 0v-4a3 3 0 10-6 0v4m6 0H7"
/>
</svg>
<h3 className="text-lg font-medium text-gray-900 mb-2">No teams yet</h3>
<p className="text-gray-600">Create your first team to get started</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{teams.map((team) => (
<article
key={team.id}
className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm"
data-testid="team-card"
>
<h3 className="text-lg font-semibold text-gray-900">{team.name}</h3>
{team.description ? (
<p className="mt-1 text-sm text-gray-600">{team.description}</p>
) : (
<p className="mt-1 text-sm text-gray-400 italic">No description</p>
)}
<div className="mt-4 flex items-center gap-3 text-xs text-gray-500">
<span>{team._count?.members ?? 0} members</span>
<span>|</span>
<span>Created {new Date(team.createdAt).toLocaleDateString()}</span>
</div>
</article>
))}
</div>
)}
</div>
</main>
);
}

View File

@@ -1,522 +0,0 @@
"use client";
import {
useCallback,
useEffect,
useState,
type ChangeEvent,
type ReactElement,
type SyntheticEvent,
} from "react";
import Link from "next/link";
import { Pencil, UserPlus, UserX } from "lucide-react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
deactivateUser,
fetchAdminUsers,
inviteUser,
updateUser,
type AdminUser,
type AdminUsersResponse,
type InviteUserDto,
type UpdateUserDto,
} from "@/lib/api/admin";
const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = {
[WorkspaceMemberRole.OWNER]: 4,
[WorkspaceMemberRole.ADMIN]: 3,
[WorkspaceMemberRole.MEMBER]: 2,
[WorkspaceMemberRole.GUEST]: 1,
};
const INITIAL_INVITE_FORM = {
email: "",
name: "",
workspaceId: "",
role: WorkspaceMemberRole.MEMBER,
};
function toRoleLabel(role: WorkspaceMemberRole): string {
return `${role.charAt(0)}${role.slice(1).toLowerCase()}`;
}
function getPrimaryRole(user: AdminUser): WorkspaceMemberRole | null {
const [firstMembership, ...restMemberships] = user.workspaceMemberships;
if (!firstMembership) {
return null;
}
return restMemberships.reduce((highest, membership) => {
if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest]) {
return membership.role;
}
return highest;
}, firstMembership.role);
}
export default function UsersSettingsPage(): ReactElement {
const [users, setUsers] = useState<AdminUser[]>([]);
const [meta, setMeta] = useState<AdminUsersResponse["meta"] | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [isInviteOpen, setIsInviteOpen] = useState<boolean>(false);
const [inviteForm, setInviteForm] = useState(INITIAL_INVITE_FORM);
const [inviteError, setInviteError] = useState<string | null>(null);
const [isInviting, setIsInviting] = useState<boolean>(false);
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
const [isDeactivating, setIsDeactivating] = useState<boolean>(false);
const [editTarget, setEditTarget] = useState<AdminUser | null>(null);
const [editName, setEditName] = useState<string>("");
const [editError, setEditError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
try {
if (showLoadingState) {
setIsLoading(true);
} else {
setIsRefreshing(true);
}
const response = await fetchAdminUsers(1, 50);
setUsers(response.data);
setMeta(response.meta);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load admin users");
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
void loadUsers(true);
}, [loadUsers]);
function resetInviteForm(): void {
setInviteForm(INITIAL_INVITE_FORM);
setInviteError(null);
}
function handleInviteOpenChange(open: boolean): void {
if (!open && !isInviting) {
resetInviteForm();
}
setIsInviteOpen(open);
}
async function handleInviteSubmit(e: SyntheticEvent): Promise<void> {
e.preventDefault();
setInviteError(null);
const email = inviteForm.email.trim();
if (!email) {
setInviteError("Email is required.");
return;
}
const dto: InviteUserDto = { email };
const name = inviteForm.name.trim();
if (name) {
dto.name = name;
}
const workspaceId = inviteForm.workspaceId.trim();
if (workspaceId) {
dto.workspaceId = workspaceId;
dto.role = inviteForm.role;
}
try {
setIsInviting(true);
await inviteUser(dto);
setIsInviteOpen(false);
resetInviteForm();
await loadUsers(false);
} catch (err: unknown) {
setInviteError(err instanceof Error ? err.message : "Failed to invite user");
} finally {
setIsInviting(false);
}
}
async function confirmDeactivate(): Promise<void> {
if (!deactivateTarget) {
return;
}
try {
setIsDeactivating(true);
await deactivateUser(deactivateTarget.id);
setDeactivateTarget(null);
await loadUsers(false);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to deactivate user");
} finally {
setIsDeactivating(false);
}
}
async function handleEditSubmit(): Promise<void> {
if (editTarget === null) return;
setIsEditing(true);
setEditError(null);
try {
const dto: UpdateUserDto = {};
if (editName.trim()) dto.name = editName.trim();
await updateUser(editTarget.id, dto);
setEditTarget(null);
await loadUsers(false);
} catch (err: unknown) {
setEditError(err instanceof Error ? err.message : "Failed to update user");
} finally {
setIsEditing(false);
}
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">Users</h1>
{meta ? <Badge variant="outline">{meta.total} total</Badge> : null}
</div>
<p className="text-muted-foreground mt-1">Invite and manage workspace users</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => {
void loadUsers(false);
}}
disabled={isLoading || isRefreshing}
>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
<Dialog open={isInviteOpen} onOpenChange={handleInviteOpenChange}>
<DialogTrigger asChild>
<Button>
<UserPlus className="h-4 w-4 mr-2" />
Invite User
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>
Create an invited account and optionally assign workspace access.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(e) => {
void handleInviteSubmit(e);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="invite-email">Email</Label>
<Input
id="invite-email"
type="email"
value={inviteForm.email}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, email: e.target.value }));
}}
placeholder="user@example.com"
maxLength={255}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-name">Name (optional)</Label>
<Input
id="invite-name"
type="text"
value={inviteForm.name}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, name: e.target.value }));
}}
placeholder="Jane Doe"
maxLength={255}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-workspace-id">Workspace ID (optional)</Label>
<Input
id="invite-workspace-id"
type="text"
value={inviteForm.workspaceId}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, workspaceId: e.target.value }));
}}
placeholder="UUID workspace id"
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-role">Role</Label>
<Select
value={inviteForm.role}
onValueChange={(value) => {
setInviteForm((prev) => ({ ...prev, role: value as WorkspaceMemberRole }));
}}
>
<SelectTrigger id="invite-role">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{Object.values(WorkspaceMemberRole).map((role) => (
<SelectItem key={role} value={role}>
{toRoleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Role is only applied when workspace ID is provided.
</p>
</div>
{inviteError ? (
<p className="text-sm text-destructive" role="alert">
{inviteError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
handleInviteOpenChange(false);
}}
disabled={isInviting}
>
Cancel
</Button>
<Button type="submit" disabled={isInviting}>
{isInviting ? "Inviting..." : "Send Invite"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</div>
<div>
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
Back to Settings
</Link>
</div>
{error ? (
<Card>
<CardContent className="py-4">
<p className="text-sm text-destructive" role="alert">
{error}
</p>
</CardContent>
</Card>
) : null}
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading users...
</CardContent>
</Card>
) : users.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>No Users Yet</CardTitle>
<CardDescription>Invite the first user to get started.</CardDescription>
</CardHeader>
</Card>
) : (
<Card>
<CardHeader>
<CardTitle>User Directory</CardTitle>
<CardDescription>Name, email, role, and account status.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{users.map((user) => {
const primaryRole = getPrimaryRole(user);
const isActive = user.deactivatedAt === null;
return (
<div
key={user.id}
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
>
<div className="space-y-1 min-w-0">
<p className="font-semibold truncate">{user.name || "Unnamed User"}</p>
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
</div>
<div className="flex items-center gap-2 flex-wrap md:justify-end">
<Badge variant="outline">
{primaryRole ? toRoleLabel(primaryRole) : "No role"}
</Badge>
<Badge variant={isActive ? "secondary" : "destructive"}>
{isActive ? "Active" : "Inactive"}
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditTarget(user);
setEditName(user.name);
setEditError(null);
}}
>
<Pencil className="h-4 w-4 mr-2" />
Edit Role
</Button>
{isActive ? (
<Button
variant="destructive"
size="sm"
onClick={() => {
setDeactivateTarget(user);
}}
>
<UserX className="h-4 w-4 mr-2" />
Deactivate
</Button>
) : null}
</div>
</div>
);
})}
</CardContent>
</Card>
)}
<AlertDialog
open={deactivateTarget !== null}
onOpenChange={(open) => {
if (!open && !isDeactivating) {
setDeactivateTarget(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Deactivate User</AlertDialogTitle>
<AlertDialogDescription>
Deactivate {deactivateTarget?.email}? They will no longer be able to access the
system.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeactivating}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isDeactivating}
onClick={() => {
void confirmDeactivate();
}}
>
{isDeactivating ? "Deactivating..." : "Deactivate"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
<Dialog
open={editTarget !== null}
onOpenChange={(open) => {
if (!open && !isEditing) {
setEditTarget(null);
setEditError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User Role</DialogTitle>
<DialogDescription>Change role for {editTarget?.email ?? "user"}.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{editError !== null ? <p className="text-sm text-destructive">{editError}</p> : null}
<div className="space-y-2">
<Label htmlFor="edit-name">Display Name</Label>
<Input
id="edit-name"
value={editName}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setEditName(e.target.value);
}}
placeholder="Full name"
disabled={isEditing}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setEditTarget(null);
}}
disabled={isEditing}
>
Cancel
</Button>
<Button
onClick={() => {
void handleEditSubmit();
}}
disabled={isEditing}
>
{isEditing ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
}

View File

@@ -1,64 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import WorkspaceDetailPage from "./page";
import * as workspacesApi from "@/lib/api/workspaces";
vi.mock("next/navigation", () => ({
useParams: (): { id: string } => ({ id: "ws-test-1" }),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: (): { user: { id: string } } => ({ user: { id: "u1" } }),
}));
vi.mock("@/lib/api/workspaces");
vi.mock("@/components/workspace/MemberList", () => ({
MemberList: ({ members }: { members: unknown[] }): React.ReactElement => (
<div data-testid="member-list">Members: {members.length}</div>
),
}));
const mockWorkspace = {
id: "ws-test-1",
name: "Test Workspace",
ownerId: "u1",
role: "OWNER",
createdAt: "2024-01-01",
};
const mockMembers = [
{
workspaceId: "ws-test-1",
userId: "u1",
role: "OWNER",
joinedAt: "2024-01-01",
user: { id: "u1", email: "a@b.com", name: "Alice", image: null },
},
];
beforeEach(() => {
vi.clearAllMocks();
});
describe("WorkspaceDetailPage", () => {
it("loads and renders member list", async (): Promise<void> => {
vi.mocked(workspacesApi.fetchUserWorkspaces).mockResolvedValue([mockWorkspace] as never);
vi.mocked(workspacesApi.fetchWorkspaceMembers).mockResolvedValue(mockMembers as never);
render(<WorkspaceDetailPage />);
await waitFor(() => {
expect(screen.getByTestId("member-list")).toBeInTheDocument();
});
expect(screen.getByText("Test Workspace")).toBeInTheDocument();
});
it("shows error state on fetch failure, not member list", async (): Promise<void> => {
vi.mocked(workspacesApi.fetchUserWorkspaces).mockRejectedValue(new Error("Network error"));
vi.mocked(workspacesApi.fetchWorkspaceMembers).mockRejectedValue(new Error("Network error"));
render(<WorkspaceDetailPage />);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeInTheDocument();
});
expect(screen.queryByTestId("member-list")).not.toBeInTheDocument();
});
});

View File

@@ -1,148 +1,178 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { WorkspaceSettings } from "@/components/workspace/WorkspaceSettings";
import { MemberList } from "@/components/workspace/MemberList";
import { useAuth } from "@/lib/auth/auth-context";
import { InviteMember } from "@/components/workspace/InviteMember";
import { WorkspaceMemberRole } from "@mosaic/shared";
import {
fetchWorkspaceMembers,
fetchUserWorkspaces,
updateWorkspaceMemberRole,
removeWorkspaceMember,
type WorkspaceMemberEntry,
type UserWorkspace,
} from "@/lib/api/workspaces";
import type { WorkspaceMemberWithUser } from "@/components/workspace/MemberList";
import type { Workspace, WorkspaceMemberWithUser } from "@mosaic/shared";
import Link from "next/link";
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) return error.message;
return fallback;
}
function toMemberWithUser(m: WorkspaceMemberEntry): WorkspaceMemberWithUser {
return {
workspaceId: m.workspaceId,
userId: m.userId,
role: m.role,
joinedAt: new Date(m.joinedAt),
user: {
id: m.user.id,
email: m.user.email,
name: m.user.name ?? "",
image: m.user.image,
emailVerified: true,
authProviderId: null,
preferences: {},
deactivatedAt: null,
isLocalAuth: false,
passwordHash: null,
invitedBy: null,
invitationToken: null,
invitedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
},
interface WorkspaceDetailPageProps {
params: {
id: string;
};
}
export default function WorkspaceDetailPage(): React.ReactElement {
const params = useParams<{ id: string }>();
const workspaceId = params.id;
const { user: authUser } = useAuth();
// Mock data - TODO: Replace with real API calls
const mockWorkspace: Workspace = {
id: "ws-1",
name: "Personal Workspace",
ownerId: "user-1",
settings: {},
createdAt: new Date("2024-01-15"),
updatedAt: new Date("2024-01-15"),
};
const [workspace, setWorkspace] = useState<UserWorkspace | null>(null);
const [members, setMembers] = useState<WorkspaceMemberEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const [workspaces, memberList] = await Promise.all([
fetchUserWorkspaces(),
fetchWorkspaceMembers(workspaceId),
]);
const ws = workspaces.find((w) => w.id === workspaceId) ?? null;
setWorkspace(ws);
setMembers(memberList);
} catch (err) {
setError(getErrorMessage(err, "Failed to load workspace"));
} finally {
setIsLoading(false);
}
}, [workspaceId]);
useEffect(() => {
void load();
}, [load]);
const handleRoleChange = useCallback(
async (userId: string, newRole: WorkspaceMemberRole): Promise<void> => {
await updateWorkspaceMemberRole(workspaceId, userId, { role: newRole });
await load();
const mockMembers: WorkspaceMemberWithUser[] = [
{
workspaceId: "ws-1",
userId: "user-1",
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2024-01-15"),
user: {
id: "user-1",
email: "owner@example.com",
name: "John Doe",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
createdAt: new Date("2024-01-15"),
updatedAt: new Date("2024-01-15"),
},
[workspaceId, load]
);
const handleRemove = useCallback(
async (userId: string): Promise<void> => {
await removeWorkspaceMember(workspaceId, userId);
await load();
},
{
workspaceId: "ws-1",
userId: "user-2",
role: WorkspaceMemberRole.ADMIN,
joinedAt: new Date("2024-01-16"),
user: {
id: "user-2",
email: "admin@example.com",
name: "Jane Smith",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
createdAt: new Date("2024-01-16"),
updatedAt: new Date("2024-01-16"),
},
[workspaceId, load]
);
},
{
workspaceId: "ws-1",
userId: "user-3",
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2024-01-17"),
user: {
id: "user-3",
email: "member@example.com",
name: "Bob Johnson",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
createdAt: new Date("2024-01-17"),
updatedAt: new Date("2024-01-17"),
},
},
];
if (isLoading) {
return (
<div className="p-8 text-center text-gray-500" role="status" aria-label="Loading workspace">
Loading workspace
</div>
export default function WorkspaceDetailPage({
params,
}: WorkspaceDetailPageProps): React.JSX.Element {
const router = useRouter();
const [workspace, setWorkspace] = useState<Workspace>(mockWorkspace);
const [members, setMembers] = useState<WorkspaceMemberWithUser[]>(mockMembers);
const currentUserId = "user-1"; // TODO: Get from auth context
const currentUserRole: WorkspaceMemberRole = WorkspaceMemberRole.OWNER; // TODO: Get from API
// TODO: Replace with actual role check when API is implemented
// Currently hardcoded to OWNER in mock data (line 89)
const canInvite =
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
currentUserRole === WorkspaceMemberRole.OWNER || currentUserRole === WorkspaceMemberRole.ADMIN;
const handleUpdateWorkspace = async (name: string): Promise<void> => {
// TODO: Replace with real API call
console.log("Updating workspace:", { id: params.id, name });
await new Promise((resolve) => setTimeout(resolve, 500));
setWorkspace({ ...workspace, name, updatedAt: new Date() });
};
const handleDeleteWorkspace = async (): Promise<void> => {
// TODO: Replace with real API call
console.log("Deleting workspace:", params.id);
await new Promise((resolve) => setTimeout(resolve, 1000));
router.push("/settings/workspaces");
};
const handleRoleChange = async (userId: string, newRole: WorkspaceMemberRole): Promise<void> => {
// TODO: Replace with real API call
console.log("Changing role:", { userId, newRole });
await new Promise((resolve) => setTimeout(resolve, 500));
setMembers(
members.map((member) => (member.userId === userId ? { ...member, role: newRole } : member))
);
}
};
if (error) {
return (
<div className="p-8">
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700" role="alert">
<p className="font-medium">Failed to load workspace</p>
<p className="mt-1 text-sm">Please try again later.</p>
</div>
<Link
href="/settings/workspaces"
className="mt-4 inline-block text-sm text-blue-600 hover:underline"
>
Back to Workspaces
</Link>
</div>
);
}
const handleRemoveMember = async (userId: string): Promise<void> => {
// TODO: Replace with real API call
console.log("Removing member:", userId);
await new Promise((resolve) => setTimeout(resolve, 500));
setMembers(members.filter((member) => member.userId !== userId));
};
const currentUserId = authUser?.id ?? "";
const currentMember = members.find((m) => m.userId === currentUserId);
const currentUserRole = currentMember?.role ?? workspace?.role ?? WorkspaceMemberRole.MEMBER;
const ownerId =
members.find((m) => m.role === WorkspaceMemberRole.OWNER)?.userId ?? workspace?.ownerId ?? "";
const handleInviteMember = async (email: string, role: WorkspaceMemberRole): Promise<void> => {
// TODO: Replace with real API call
console.log("Inviting member:", { email, role, workspaceId: params.id });
await new Promise((resolve) => setTimeout(resolve, 1000));
// In real implementation, this would send an invitation email
};
return (
<div className="p-8 max-w-3xl">
<div className="mb-6">
<Link href="/settings/workspaces" className="text-sm text-gray-500 hover:text-gray-700">
Back to Workspaces
</Link>
<h1 className="mt-2 text-2xl font-bold text-gray-900">{workspace?.name ?? "Workspace"}</h1>
<main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h1 className="text-3xl font-bold text-gray-900">{workspace.name}</h1>
<Link href="/settings/workspaces" className="text-sm text-blue-600 hover:text-blue-700">
Back to Workspaces
</Link>
</div>
<p className="text-gray-600">Manage workspace settings and team members</p>
</div>
<MemberList
members={members.map(toMemberWithUser)}
currentUserId={currentUserId}
currentUserRole={currentUserRole}
workspaceOwnerId={ownerId}
onRoleChange={handleRoleChange}
onRemove={handleRemove}
/>
</div>
<div className="space-y-6">
{/* Workspace Settings */}
<WorkspaceSettings
workspace={workspace}
userRole={currentUserRole}
onUpdate={handleUpdateWorkspace}
onDelete={handleDeleteWorkspace}
/>
{/* Members Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="lg:col-span-2">
<MemberList
members={members}
currentUserId={currentUserId}
currentUserRole={currentUserRole}
workspaceOwnerId={workspace.ownerId}
onRoleChange={handleRoleChange}
onRemove={handleRemoveMember}
/>
</div>
{/* Invite Member */}
{canInvite && (
<div className="lg:col-span-2">
<InviteMember onInvite={handleInviteMember} />
</div>
)}
</div>
</div>
</main>
);
}

View File

@@ -1,135 +1,60 @@
import type { UserWorkspace } from "@/lib/api/workspaces";
import type { ReactElement, ReactNode } from "react";
/**
* Workspaces Page Tests
* Tests for page structure and component integration
*/
import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createWorkspace, fetchUserWorkspaces } from "@/lib/api/workspaces";
import WorkspacesPage from "./page";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
// Mock next/link
vi.mock("next/link", () => ({
default: function LinkMock({
children,
href,
}: {
children: ReactNode;
href: string;
}): ReactElement {
return <a href={href}>{children}</a>;
},
default: ({ children, href }: { children: React.ReactNode; href: string }): React.JSX.Element => (
<a href={href}>{children}</a>
),
}));
// Mock the WorkspaceCard component
vi.mock("@/components/workspace/WorkspaceCard", () => ({
WorkspaceCard: function WorkspaceCardMock({
workspace,
userRole,
memberCount,
}: {
workspace: { name: string };
userRole: WorkspaceMemberRole;
memberCount: number;
}): ReactElement {
return (
<div data-testid="workspace-card">
{workspace.name} | {userRole} | {String(memberCount)}
</div>
);
},
WorkspaceCard: (): React.JSX.Element => <div data-testid="workspace-card">WorkspaceCard</div>,
}));
vi.mock("@/lib/api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(),
createWorkspace: vi.fn(),
}));
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const createWorkspaceMock = vi.mocked(createWorkspace);
const baseWorkspace: UserWorkspace = {
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.OWNER,
createdAt: "2026-01-01T00:00:00.000Z",
};
describe("WorkspacesPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("loads and renders user workspaces from the API", async () => {
fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]);
describe("WorkspacesPage", (): void => {
// Note: NODE_ENV is "test" during test runs, which triggers the Coming Soon view
// This tests the production-like behavior where mock data is hidden
it("should render the Coming Soon view in non-development environments", async (): Promise<void> => {
const { default: WorkspacesPage } = await import("./page");
render(<WorkspacesPage />);
expect(screen.getByText("Loading workspaces...")).toBeInTheDocument();
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
expect(screen.getByTestId("workspace-card")).toHaveTextContent("Personal Workspace");
expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(1);
// In test mode (non-development), should show Coming Soon
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
expect(screen.getByText("Workspace Management")).toBeInTheDocument();
});
it("shows fetch errors in the UI", async () => {
fetchUserWorkspacesMock.mockRejectedValue(new Error("Unable to load workspaces"));
it("should display appropriate description for workspace feature", async (): Promise<void> => {
const { default: WorkspacesPage } = await import("./page");
render(<WorkspacesPage />);
expect(await screen.findByText("Unable to load workspaces")).toBeInTheDocument();
expect(
screen.getByText(/create and manage workspaces to organize your projects/i)
).toBeInTheDocument();
});
it("creates a workspace and refreshes the list", async () => {
fetchUserWorkspacesMock.mockResolvedValueOnce([baseWorkspace]).mockResolvedValueOnce([
baseWorkspace,
{
...baseWorkspace,
id: "workspace-2",
name: "New Workspace",
role: WorkspaceMemberRole.MEMBER,
},
]);
createWorkspaceMock.mockResolvedValue({
id: "workspace-2",
name: "New Workspace",
ownerId: "owner-1",
settings: {},
createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z",
memberCount: 1,
});
const user = userEvent.setup();
it("should not render mock workspace data in Coming Soon view", async (): Promise<void> => {
const { default: WorkspacesPage } = await import("./page");
render(<WorkspacesPage />);
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "New Workspace");
await user.click(screen.getByRole("button", { name: "Create Workspace" }));
await waitFor(() => {
expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "New Workspace" });
});
await waitFor(() => {
expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(2);
});
expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument();
// Should not show workspace cards or create form in non-development mode
expect(screen.queryByTestId("workspace-card")).not.toBeInTheDocument();
expect(screen.queryByText("Create New Workspace")).not.toBeInTheDocument();
});
it("shows create errors in the UI", async () => {
fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]);
createWorkspaceMock.mockRejectedValue(new Error("Workspace creation failed"));
const user = userEvent.setup();
it("should include link back to settings", async (): Promise<void> => {
const { default: WorkspacesPage } = await import("./page");
render(<WorkspacesPage />);
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Bad Workspace");
await user.click(screen.getByRole("button", { name: "Create Workspace" }));
expect(await screen.findByText("Workspace creation failed")).toBeInTheDocument();
const link = screen.getByRole("link", { name: /back to settings/i });
expect(link).toBeInTheDocument();
expect(link).toHaveAttribute("href", "/settings");
});
});

View File

@@ -1,74 +1,72 @@
"use client";
import type { ReactElement, SyntheticEvent } from "react";
import type { ReactElement } from "react";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { WorkspaceCard } from "@/components/workspace/WorkspaceCard";
import { createWorkspace, fetchUserWorkspaces, type UserWorkspace } from "@/lib/api/workspaces";
import { ComingSoon } from "@/components/ui/ComingSoon";
import { WorkspaceMemberRole } from "@mosaic/shared";
import Link from "next/link";
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) {
return error.message;
}
// Check if we're in development mode
const isDevelopment = process.env.NODE_ENV === "development";
return fallback;
}
// Mock data - TODO: Replace with real API calls (development only)
const mockWorkspaces = [
{
id: "ws-1",
name: "Personal Workspace",
ownerId: "user-1",
settings: {},
createdAt: new Date("2024-01-15"),
updatedAt: new Date("2024-01-15"),
},
{
id: "ws-2",
name: "Team Alpha",
ownerId: "user-2",
settings: {},
createdAt: new Date("2024-01-20"),
updatedAt: new Date("2024-01-20"),
},
];
const mockMemberships = [
{ workspaceId: "ws-1", role: WorkspaceMemberRole.OWNER, memberCount: 1 },
{ workspaceId: "ws-2", role: WorkspaceMemberRole.MEMBER, memberCount: 5 },
];
/**
* Workspaces Page
* Fetches and creates workspaces through the real API.
* Workspaces Page Content - Development Only
* Shows mock workspace data for development purposes
*/
export default function WorkspacesPage(): ReactElement {
const [workspaces, setWorkspaces] = useState<UserWorkspace[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
function WorkspacesPageContent(): ReactElement {
const [isCreating, setIsCreating] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const loadWorkspaces = useCallback(async (): Promise<void> => {
setIsLoading(true);
// TODO: Replace with real API call
const workspacesWithRoles = mockWorkspaces.map((workspace) => {
const membership = mockMemberships.find((m) => m.workspaceId === workspace.id);
return {
...workspace,
userRole: membership?.role ?? WorkspaceMemberRole.GUEST,
memberCount: membership?.memberCount ?? 0,
};
});
try {
const data = await fetchUserWorkspaces();
setWorkspaces(data);
setLoadError(null);
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load workspaces"));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadWorkspaces();
}, [loadWorkspaces]);
const workspacesWithRoles = workspaces.map((workspace) => ({
...workspace,
settings: {},
createdAt: new Date(workspace.createdAt),
updatedAt: new Date(workspace.createdAt),
userRole: workspace.role,
memberCount: 1,
}));
const handleCreateWorkspace = async (e: SyntheticEvent<HTMLFormElement>): Promise<void> => {
const handleCreateWorkspace = async (e: React.SyntheticEvent<HTMLFormElement>): Promise<void> => {
e.preventDefault();
const workspaceName = newWorkspaceName.trim();
if (!workspaceName) return;
if (!newWorkspaceName.trim()) return;
setIsCreating(true);
setCreateError(null);
try {
await createWorkspace({ name: workspaceName });
// TODO: Replace with real API call
await new Promise((resolve) => setTimeout(resolve, 1000)); // Simulate API call
alert(`Workspace "${newWorkspaceName}" created successfully!`);
setNewWorkspaceName("");
await loadWorkspaces();
} catch (error) {
setCreateError(getErrorMessage(error, "Failed to create workspace"));
} catch (_error) {
console.error("Failed to create workspace:", _error);
alert("Failed to create workspace");
} finally {
setIsCreating(false);
}
@@ -108,27 +106,14 @@ export default function WorkspacesPage(): ReactElement {
{isCreating ? "Creating..." : "Create Workspace"}
</button>
</form>
{createError !== null && (
<div className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{createError}
</div>
)}
</div>
{/* Workspace List */}
<div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900">
Your Workspaces ({isLoading ? "..." : workspacesWithRoles.length})
Your Workspaces ({workspacesWithRoles.length})
</h2>
{loadError !== null ? (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{loadError}
</div>
) : isLoading ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center text-gray-600">
Loading workspaces...
</div>
) : workspacesWithRoles.length === 0 ? (
{workspacesWithRoles.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg
className="mx-auto h-12 w-12 text-gray-400 mb-4"
@@ -162,3 +147,26 @@ export default function WorkspacesPage(): ReactElement {
</main>
);
}
/**
* Workspaces Page Entry Point
* Shows development content or Coming Soon based on environment
*/
export default function WorkspacesPage(): ReactElement {
// In production, show Coming Soon placeholder
if (!isDevelopment) {
return (
<ComingSoon
feature="Workspace Management"
description="Create and manage workspaces to organize your projects and collaborate with your team. This feature is currently under development."
>
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
Back to Settings
</Link>
</ComingSoon>
);
}
// In development, show the full page with mock data
return <WorkspacesPageContent />;
}

View File

@@ -1,63 +0,0 @@
"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>
</>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 539 B

View File

@@ -11,9 +11,6 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Mosaic Stack",
description: "Mosaic Stack Web Application",
icons: {
icon: "/favicon.ico",
},
};
const outfit = Outfit({

Some files were not shown because too many files have changed in this diff Show More