Compare commits

..

2 Commits

Author SHA1 Message Date
a7fbc1ccc8 fix(api): fix lint errors in lazy node-pty import types
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Use proper import type instead of inline import() type annotations
that violate @typescript-eslint/consistent-type-imports rule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 07:45:36 -06:00
d18cf44546 fix(api): lazy-load node-pty to prevent API crash when native binary is missing
node-pty requires a compiled native addon (.node binary) that may not
be available in all Docker environments. The eager import crashed the
entire API at startup. Changed to dynamic import() in onModuleInit()
so the service degrades gracefully — terminal sessions are disabled
but all other API functionality works.

Also added explicit node-gyp rebuild to Dockerfile deps stage since
pnpm may skip postinstall scripts for native addons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 07:44:47 -06:00
137 changed files with 1368 additions and 11931 deletions

View File

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

View File

@@ -1,90 +1,14 @@
{ {
"schema_version": 1, "schema_version": 1,
"mission_id": "ms21-multi-tenant-rbac-data-migration-20260228", "mission_id": "prd-implementation-20260222",
"name": "MS21 Multi-Tenant RBAC Data Migration", "name": "PRD implementation",
"description": "Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack", "description": "",
"project_path": "/home/jwoltje/src/mosaic-stack", "project_path": "/home/jwoltje/src/mosaic-stack",
"created_at": "2026-02-28T17:10:22Z", "created_at": "2026-02-23T03:20:55Z",
"status": "active", "status": "active",
"task_prefix": "MS21", "task_prefix": "",
"quality_gates": "pnpm lint && pnpm build && pnpm test", "quality_gates": "",
"milestone_version": "0.0.21", "milestone_version": "0.0.1",
"milestones": [ "milestones": [],
{ "sessions": []
"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": ""
}
]
} }

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

View File

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

View File

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

View File

@@ -46,21 +46,6 @@ pnpm lint
pnpm build 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 ## Standards and Quality
- Enforce strict typing and no unsafe shortcuts. - Enforce strict typing and no unsafe shortcuts.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/api", "name": "@mosaic/api",
"version": "0.0.20", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nest build", "build": "nest build",
@@ -52,7 +52,6 @@
"adm-zip": "^0.5.16", "adm-zip": "^0.5.16",
"archiver": "^7.0.1", "archiver": "^7.0.1",
"axios": "^1.13.5", "axios": "^1.13.5",
"bcryptjs": "^3.0.3",
"better-auth": "^1.4.17", "better-auth": "^1.4.17",
"bullmq": "^5.67.2", "bullmq": "^5.67.2",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -86,7 +85,6 @@
"@swc/core": "^1.10.18", "@swc/core": "^1.10.18",
"@types/adm-zip": "^0.5.7", "@types/adm-zip": "^0.5.7",
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/bcryptjs": "^3.0.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/highlight.js": "^10.1.0", "@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 { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"] previewFeatures = ["postgresqlExtensions"]
} }
@@ -227,14 +226,6 @@ model User {
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_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 // Relations
ownedWorkspaces Workspace[] @relation("WorkspaceOwner") ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
workspaceMemberships WorkspaceMember[] workspaceMemberships WorkspaceMember[]
@@ -1076,10 +1067,6 @@ model Personality {
displayName String @map("display_name") displayName String @map("display_name")
description String? @db.Text description String? @db.Text
// Tone and formality
tone String @default("neutral")
formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
// System prompt // System prompt
systemPrompt String @map("system_prompt") @db.Text 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 { SpeechModule } from "./speech/speech.module";
import { DashboardModule } from "./dashboard/dashboard.module"; import { DashboardModule } from "./dashboard/dashboard.module";
import { TerminalModule } from "./terminal/terminal.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"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({ @Module({
@@ -110,11 +105,6 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
SpeechModule, SpeechModule,
DashboardModule, DashboardModule,
TerminalModule, TerminalModule,
PersonalitiesModule,
WorkspacesModule,
AdminModule,
TeamsModule,
ImportModule,
], ],
controllers: [AppController, CsrfController], controllers: [AppController, CsrfController],
providers: [ providers: [

View File

@@ -361,13 +361,16 @@ describe("AuthController", () => {
}); });
describe("getProfile", () => { describe("getProfile", () => {
it("should return complete user profile with identity fields", () => { it("should return complete user profile with workspace fields", () => {
const mockUser: AuthUser = { const mockUser: AuthUser = {
id: "user-123", id: "user-123",
email: "test@example.com", email: "test@example.com",
name: "Test User", name: "Test User",
image: "https://example.com/avatar.jpg", image: "https://example.com/avatar.jpg",
emailVerified: true, emailVerified: true,
workspaceId: "workspace-123",
currentWorkspaceId: "workspace-456",
workspaceRole: "admin",
}; };
const result = controller.getProfile(mockUser); const result = controller.getProfile(mockUser);
@@ -378,10 +381,13 @@ describe("AuthController", () => {
name: mockUser.name, name: mockUser.name,
image: mockUser.image, image: mockUser.image,
emailVerified: mockUser.emailVerified, 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 = { const mockUser: AuthUser = {
id: "user-123", id: "user-123",
email: "test@example.com", email: "test@example.com",
@@ -394,11 +400,12 @@ describe("AuthController", () => {
id: mockUser.id, id: mockUser.id,
email: mockUser.email, email: mockUser.email,
name: mockUser.name, 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) { if (user.emailVerified !== undefined) {
profile.emailVerified = user.emailVerified; profile.emailVerified = user.emailVerified;
} }
if (user.workspaceId !== undefined) {
// Workspace context is served by GET /api/workspaces, not the auth profile. profile.workspaceId = user.workspaceId;
// The deprecated workspaceId/currentWorkspaceId/workspaceRole fields on }
// AuthUser are never populated by BetterAuth and are omitted here. if (user.currentWorkspaceId !== undefined) {
profile.currentWorkspaceId = user.currentWorkspaceId;
}
if (user.workspaceRole !== undefined) {
profile.workspaceRole = user.workspaceRole;
}
return profile; return profile;
} }

View File

@@ -3,14 +3,11 @@ import { PrismaModule } from "../prisma/prisma.module";
import { AuthService } from "./auth.service"; import { AuthService } from "./auth.service";
import { AuthController } from "./auth.controller"; import { AuthController } from "./auth.controller";
import { AuthGuard } from "./guards/auth.guard"; 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({ @Module({
imports: [PrismaModule], imports: [PrismaModule],
controllers: [AuthController, LocalAuthController], controllers: [AuthController],
providers: [AuthService, AuthGuard, LocalAuthService, LocalAuthEnabledGuard], providers: [AuthService, AuthGuard],
exports: [AuthService, AuthGuard], exports: [AuthService, AuthGuard],
}) })
export class AuthModule {} 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; return paramWorkspaceId;
} }
// 3. Check request body (body may be undefined for GET requests despite Express typings) // 3. Check request body
const body = request.body as Record<string, unknown> | undefined; const bodyWorkspaceId = request.body.workspaceId;
if (body && typeof body.workspaceId === "string") { if (typeof bodyWorkspaceId === "string") {
return body.workspaceId; return bodyWorkspaceId;
} }
// 4. Check query string (backward compatibility for existing clients) // 4. Check query string (backward compatibility for existing clients)

View File

@@ -270,7 +270,7 @@ describe("sanitizeForLogging", () => {
const duration = Date.now() - start; const duration = Date.now() - start;
expect(result.password).toBe("[REDACTED]"); 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"); .set("X-API-Key", "test-coordinator-key");
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS); expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
}, 30000); });
}); });
describe("Per-API-Key Rate Limiting", () => { 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 { Type } from "class-transformer";
import { EntryStatus, Visibility } from "@prisma/client"; import { EntryStatus } from "@prisma/client";
/** /**
* DTO for querying knowledge entries (list endpoint) * DTO for querying knowledge entries (list endpoint)
@@ -10,28 +10,10 @@ export class EntryQueryDto {
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" }) @IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
status?: EntryStatus; status?: EntryStatus;
@IsOptional()
@IsEnum(Visibility, { message: "visibility must be a valid Visibility" })
visibility?: Visibility;
@IsOptional() @IsOptional()
@IsString({ message: "tag must be a string" }) @IsString({ message: "tag must be a string" })
tag?: 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() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt({ message: "page must be an integer" }) @IsInt({ message: "page must be an integer" })

View File

@@ -48,10 +48,6 @@ export class KnowledgeService {
where.status = query.status; where.status = query.status;
} }
if (query.visibility) {
where.visibility = query.visibility;
}
if (query.tag) { if (query.tag) {
where.tags = { where.tags = {
some: { 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 // Get total count
const total = await this.prisma.knowledgeEntry.count({ where }); const total = await this.prisma.knowledgeEntry.count({ where });
@@ -89,7 +71,9 @@ export class KnowledgeService {
}, },
}, },
}, },
orderBy, orderBy: {
updatedAt: "desc",
},
skip, skip,
take: limit, take: limit,
}); });

View File

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

View File

@@ -1,3 +1,2 @@
export * from "./create-personality.dto"; export * from "./create-personality.dto";
export * from "./update-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 {
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator"; IsString,
IsOptional,
IsBoolean,
IsNumber,
IsInt,
IsUUID,
MinLength,
MaxLength,
Min,
Max,
} from "class-validator";
/** /**
* DTO for updating an existing personality * DTO for updating an existing personality/assistant configuration
* All fields are optional; only provided fields are updated.
*/ */
export class UpdatePersonalityDto { export class UpdatePersonalityDto {
@IsOptional() @IsOptional()
@IsString({ message: "name must be a string" }) @IsString()
@MinLength(1, { message: "name must not be empty" }) @MinLength(1)
@MaxLength(255, { message: "name must not exceed 255 characters" }) @MaxLength(100)
name?: string; name?: string; // unique identifier slug
@IsOptional() @IsOptional()
@IsString({ message: "description must be a string" }) @IsString()
@MaxLength(2000, { message: "description must not exceed 2000 characters" }) @MinLength(1)
@MaxLength(200)
displayName?: string; // human-readable name
@IsOptional()
@IsString()
@MaxLength(1000)
description?: string; description?: string;
@IsOptional() @IsOptional()
@IsString({ message: "tone must be a string" }) @IsString()
@MinLength(1, { message: "tone must not be empty" }) @MinLength(10)
@MaxLength(100, { message: "tone must not exceed 100 characters" }) systemPrompt?: string;
tone?: string;
@IsOptional() @IsOptional()
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" }) @IsNumber()
formalityLevel?: FormalityLevel; @Min(0)
@Max(2)
temperature?: number; // null = use provider default
@IsOptional() @IsOptional()
@IsString({ message: "systemPromptTemplate must be a string" }) @IsInt()
@MinLength(1, { message: "systemPromptTemplate must not be empty" }) @Min(1)
systemPromptTemplate?: string; maxTokens?: number; // null = use provider default
@IsOptional() @IsOptional()
@IsBoolean({ message: "isDefault must be a boolean" }) @IsUUID("4")
llmProviderInstanceId?: string; // FK to LlmProviderInstance
@IsOptional()
@IsBoolean()
isDefault?: boolean; isDefault?: boolean;
@IsOptional() @IsOptional()
@IsBoolean({ message: "isActive must be a boolean" }) @IsBoolean()
isActive?: boolean; 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 * Personality entity representing an assistant configuration
* Maps Prisma Personality fields to the frontend API contract.
*
* Field mapping (Prisma -> API):
* systemPrompt -> systemPromptTemplate
* isEnabled -> isActive
* (tone, formalityLevel are identical in both)
*/ */
export interface PersonalityResponse { export class Personality implements PrismaPersonality {
id: string; id!: string;
workspaceId: string; workspaceId!: string;
name: string; name!: string; // unique identifier slug
description: string | null; displayName!: string; // human-readable name
tone: string; description!: string | null;
formalityLevel: FormalityLevel; systemPrompt!: string;
systemPromptTemplate: string; temperature!: number | null; // null = use provider default
isDefault: boolean; maxTokens!: number | null; // null = use provider default
isActive: boolean; llmProviderInstanceId!: string | null; // FK to LlmProviderInstance
createdAt: Date; isDefault!: boolean;
updatedAt: Date; 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 { Test, TestingModule } from "@nestjs/testing";
import { PersonalitiesController } from "./personalities.controller"; import { PersonalitiesController } from "./personalities.controller";
import { PersonalitiesService } from "./personalities.service"; import { PersonalitiesService } from "./personalities.service";
import type { CreatePersonalityDto } from "./dto/create-personality.dto"; import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { FormalityLevel } from "@prisma/client";
describe("PersonalitiesController", () => { describe("PersonalitiesController", () => {
let controller: PersonalitiesController; let controller: PersonalitiesController;
let service: PersonalitiesService; let service: PersonalitiesService;
const mockWorkspaceId = "workspace-123"; const mockWorkspaceId = "workspace-123";
const mockUserId = "user-123";
const mockPersonalityId = "personality-123"; const mockPersonalityId = "personality-123";
/** API response shape (frontend field names) */
const mockPersonality = { const mockPersonality = {
id: mockPersonalityId, id: mockPersonalityId,
workspaceId: mockWorkspaceId, workspaceId: mockWorkspaceId,
name: "professional-assistant", name: "professional-assistant",
displayName: "Professional Assistant",
description: "A professional communication assistant", description: "A professional communication assistant",
tone: "professional", systemPrompt: "You are a professional assistant who helps with tasks.",
formalityLevel: FormalityLevel.FORMAL, temperature: 0.7,
systemPromptTemplate: "You are a professional assistant who helps with tasks.", maxTokens: 2000,
llmProviderInstanceId: "provider-123",
isDefault: true, isDefault: true,
isActive: true, isEnabled: true,
createdAt: new Date("2026-01-01"), createdAt: new Date(),
updatedAt: new Date("2026-01-01"), updatedAt: new Date(),
};
const mockRequest = {
user: { id: mockUserId },
workspaceId: mockWorkspaceId,
}; };
const mockPersonalitiesService = { const mockPersonalitiesService = {
@@ -53,54 +57,24 @@ describe("PersonalitiesController", () => {
}) })
.overrideGuard(AuthGuard) .overrideGuard(AuthGuard)
.useValue({ canActivate: () => true }) .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(); .compile();
controller = module.get<PersonalitiesController>(PersonalitiesController); controller = module.get<PersonalitiesController>(PersonalitiesController);
service = module.get<PersonalitiesService>(PersonalitiesService); service = module.get<PersonalitiesService>(PersonalitiesService);
// Reset mocks
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe("findAll", () => { describe("findAll", () => {
it("should return success response with personalities list", async () => { it("should return all personalities", async () => {
const mockList = [mockPersonality]; const mockPersonalities = [mockPersonality];
mockPersonalitiesService.findAll.mockResolvedValue(mockList); mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
const result = await controller.findAll(mockWorkspaceId, {}); const result = await controller.findAll(mockRequest);
expect(result).toEqual({ success: true, data: mockList }); expect(result).toEqual(mockPersonalities);
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {}); 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);
}); });
}); });
@@ -108,29 +82,54 @@ describe("PersonalitiesController", () => {
it("should return a personality by id", async () => { it("should return a personality by id", async () => {
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality); mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
const result = await controller.findOne(mockWorkspaceId, mockPersonalityId); const result = await controller.findOne(mockRequest, mockPersonalityId);
expect(result).toEqual(mockPersonality); expect(result).toEqual(mockPersonality);
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); 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", () => { describe("create", () => {
it("should create a new personality", async () => { it("should create a new personality", async () => {
const createDto: CreatePersonalityDto = { const createDto: CreatePersonalityDto = {
name: "casual-helper", name: "casual-helper",
displayName: "Casual Helper",
description: "A casual helper", description: "A casual helper",
tone: "casual", systemPrompt: "You are a casual assistant.",
formalityLevel: FormalityLevel.CASUAL, temperature: 0.8,
systemPromptTemplate: "You are a casual assistant.", maxTokens: 1500,
}; };
const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false }; mockPersonalitiesService.create.mockResolvedValue({
mockPersonalitiesService.create.mockResolvedValue(created); ...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); expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
}); });
}); });
@@ -139,13 +138,15 @@ describe("PersonalitiesController", () => {
it("should update a personality", async () => { it("should update a personality", async () => {
const updateDto: UpdatePersonalityDto = { const updateDto: UpdatePersonalityDto = {
description: "Updated description", description: "Updated description",
tone: "enthusiastic", temperature: 0.9,
}; };
const updated = { ...mockPersonality, ...updateDto }; mockPersonalitiesService.update.mockResolvedValue({
mockPersonalitiesService.update.mockResolvedValue(updated); ...mockPersonality,
...updateDto,
});
const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto); const result = await controller.update(mockRequest, mockPersonalityId, updateDto);
expect(result).toMatchObject(updateDto); expect(result).toMatchObject(updateDto);
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto); expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
@@ -156,7 +157,7 @@ describe("PersonalitiesController", () => {
it("should delete a personality", async () => { it("should delete a personality", async () => {
mockPersonalitiesService.delete.mockResolvedValue(undefined); mockPersonalitiesService.delete.mockResolvedValue(undefined);
await controller.delete(mockWorkspaceId, mockPersonalityId); await controller.delete(mockRequest, mockPersonalityId);
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
}); });
@@ -164,10 +165,12 @@ describe("PersonalitiesController", () => {
describe("setDefault", () => { describe("setDefault", () => {
it("should set a personality as default", async () => { it("should set a personality as default", async () => {
const updated = { ...mockPersonality, isDefault: true }; mockPersonalitiesService.setDefault.mockResolvedValue({
mockPersonalitiesService.setDefault.mockResolvedValue(updated); ...mockPersonality,
isDefault: true,
});
const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId); const result = await controller.setDefault(mockRequest, mockPersonalityId);
expect(result).toMatchObject({ isDefault: true }); expect(result).toMatchObject({ isDefault: true });
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId); expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);

View File

@@ -6,122 +6,105 @@ import {
Delete, Delete,
Body, Body,
Param, Param,
Query,
UseGuards, UseGuards,
Req,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
} from "@nestjs/common"; } from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard"; 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 { PersonalitiesService } from "./personalities.service";
import { CreatePersonalityDto } from "./dto/create-personality.dto"; import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import { UpdatePersonalityDto } from "./dto/update-personality.dto"; import { Personality } from "./entities/personality.entity";
import { PersonalityQueryDto } from "./dto/personality-query.dto";
import type { PersonalityResponse } from "./entities/personality.entity"; interface AuthenticatedRequest {
user: { id: string };
workspaceId: string;
}
/** /**
* Controller for personality CRUD endpoints. * Controller for managing personality/assistant configurations
* 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("personalities") @Controller("personality")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard) @UseGuards(AuthGuard)
export class PersonalitiesController { export class PersonalitiesController {
constructor(private readonly personalitiesService: PersonalitiesService) {} constructor(private readonly personalitiesService: PersonalitiesService) {}
/** /**
* GET /api/personalities * List all personalities for the workspace
* List all personalities for the workspace.
* Supports ?isActive=true|false filter.
*/ */
@Get() @Get()
@RequirePermission(Permission.WORKSPACE_ANY) async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> {
async findAll( return this.personalitiesService.findAll(req.workspaceId);
@Workspace() workspaceId: string,
@Query() query: PersonalityQueryDto
): Promise<{ success: true; data: PersonalityResponse[] }> {
const data = await this.personalitiesService.findAll(workspaceId, query);
return { success: true, data };
} }
/** /**
* GET /api/personalities/default * Get the default personality for the workspace
* Get the default personality for the workspace.
* Must be declared before :id to avoid route conflicts.
*/ */
@Get("default") @Get("default")
@RequirePermission(Permission.WORKSPACE_ANY) async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
async findDefault(@Workspace() workspaceId: string): Promise<PersonalityResponse> { return this.personalitiesService.findDefault(req.workspaceId);
return this.personalitiesService.findDefault(workspaceId);
} }
/** /**
* GET /api/personalities/:id * Get a personality by its unique name
* Get a single personality by ID. */
@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") @Get(":id")
@RequirePermission(Permission.WORKSPACE_ANY) async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
async findOne( return this.personalitiesService.findOne(req.workspaceId, id);
@Workspace() workspaceId: string,
@Param("id") id: string
): Promise<PersonalityResponse> {
return this.personalitiesService.findOne(workspaceId, id);
} }
/** /**
* POST /api/personalities * Create a new personality
* Create a new personality.
*/ */
@Post() @Post()
@HttpCode(HttpStatus.CREATED) @HttpCode(HttpStatus.CREATED)
@RequirePermission(Permission.WORKSPACE_MEMBER)
async create( async create(
@Workspace() workspaceId: string, @Req() req: AuthenticatedRequest,
@Body() dto: CreatePersonalityDto @Body() dto: CreatePersonalityDto
): Promise<PersonalityResponse> { ): Promise<Personality> {
return this.personalitiesService.create(workspaceId, dto); return this.personalitiesService.create(req.workspaceId, dto);
} }
/** /**
* PATCH /api/personalities/:id * Update a personality
* Update an existing personality.
*/ */
@Patch(":id") @Patch(":id")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async update( async update(
@Workspace() workspaceId: string, @Req() req: AuthenticatedRequest,
@Param("id") id: string, @Param("id") id: string,
@Body() dto: UpdatePersonalityDto @Body() dto: UpdatePersonalityDto
): Promise<PersonalityResponse> { ): Promise<Personality> {
return this.personalitiesService.update(workspaceId, id, dto); return this.personalitiesService.update(req.workspaceId, id, dto);
} }
/** /**
* DELETE /api/personalities/:id * Delete a personality
* Delete a personality.
*/ */
@Delete(":id") @Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.WORKSPACE_MEMBER) async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise<void> { return this.personalitiesService.delete(req.workspaceId, id);
return this.personalitiesService.delete(workspaceId, id);
} }
/** /**
* POST /api/personalities/:id/set-default * Set a personality as the default
* Convenience endpoint to set a personality as the default.
*/ */
@Post(":id/set-default") @Post(":id/set-default")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async setDefault( async setDefault(
@Workspace() workspaceId: string, @Req() req: AuthenticatedRequest,
@Param("id") id: string @Param("id") id: string
): Promise<PersonalityResponse> { ): Promise<Personality> {
return this.personalitiesService.setDefault(workspaceId, id); 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 { Test, TestingModule } from "@nestjs/testing";
import { PersonalitiesService } from "./personalities.service"; import { PersonalitiesService } from "./personalities.service";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import type { CreatePersonalityDto } from "./dto/create-personality.dto"; import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
import { NotFoundException, ConflictException } from "@nestjs/common"; import { NotFoundException, ConflictException } from "@nestjs/common";
import { FormalityLevel } from "@prisma/client";
describe("PersonalitiesService", () => { describe("PersonalitiesService", () => {
let service: PersonalitiesService; let service: PersonalitiesService;
@@ -13,39 +11,22 @@ describe("PersonalitiesService", () => {
const mockWorkspaceId = "workspace-123"; const mockWorkspaceId = "workspace-123";
const mockPersonalityId = "personality-123"; const mockPersonalityId = "personality-123";
const mockProviderId = "provider-123";
/** Raw Prisma record shape (uses Prisma field names) */ const mockPersonality = {
const mockPrismaRecord = {
id: mockPersonalityId, id: mockPersonalityId,
workspaceId: mockWorkspaceId, workspaceId: mockWorkspaceId,
name: "professional-assistant", name: "professional-assistant",
displayName: "Professional Assistant", displayName: "Professional Assistant",
description: "A professional communication assistant", description: "A professional communication assistant",
tone: "professional",
formalityLevel: FormalityLevel.FORMAL,
systemPrompt: "You are a professional assistant who helps with tasks.", systemPrompt: "You are a professional assistant who helps with tasks.",
temperature: 0.7, temperature: 0.7,
maxTokens: 2000, maxTokens: 2000,
llmProviderInstanceId: "provider-123", llmProviderInstanceId: mockProviderId,
isDefault: true, isDefault: true,
isEnabled: true, isEnabled: true,
createdAt: new Date("2026-01-01"), createdAt: new Date(),
updatedAt: new Date("2026-01-01"), updatedAt: new Date(),
};
/** Expected API response shape (uses frontend field names) */
const mockResponse = {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
name: "professional-assistant",
description: "A professional communication assistant",
tone: "professional",
formalityLevel: FormalityLevel.FORMAL,
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
isDefault: true,
isActive: true,
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
}; };
const mockPrismaService = { const mockPrismaService = {
@@ -56,7 +37,9 @@ describe("PersonalitiesService", () => {
create: vi.fn(), create: vi.fn(),
update: vi.fn(), update: vi.fn(),
delete: vi.fn(), delete: vi.fn(),
count: vi.fn(),
}, },
$transaction: vi.fn((callback) => callback(mockPrismaService)),
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -73,54 +56,44 @@ describe("PersonalitiesService", () => {
service = module.get<PersonalitiesService>(PersonalitiesService); service = module.get<PersonalitiesService>(PersonalitiesService);
prisma = module.get<PrismaService>(PrismaService); prisma = module.get<PrismaService>(PrismaService);
// Reset mocks
vi.clearAllMocks(); vi.clearAllMocks();
}); });
describe("create", () => { describe("create", () => {
const createDto: CreatePersonalityDto = { const createDto: CreatePersonalityDto = {
name: "casual-helper", name: "casual-helper",
displayName: "Casual Helper",
description: "A casual communication helper", description: "A casual communication helper",
tone: "casual", systemPrompt: "You are a casual assistant.",
formalityLevel: FormalityLevel.CASUAL, temperature: 0.8,
systemPromptTemplate: "You are a casual assistant.", maxTokens: 1500,
isDefault: false, llmProviderInstanceId: mockProviderId,
isActive: true,
}; };
const createdRecord = { it("should create a new personality", async () => {
...mockPrismaRecord,
name: createDto.name,
description: createDto.description,
tone: createDto.tone,
formalityLevel: createDto.formalityLevel,
systemPrompt: createDto.systemPromptTemplate,
isDefault: false,
isEnabled: true,
id: "new-personality-id",
};
it("should create a new personality and return API response shape", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null); mockPrismaService.personality.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); const result = await service.create(mockWorkspaceId, createDto);
expect(result.name).toBe(createDto.name); expect(result).toMatchObject(createDto);
expect(result.tone).toBe(createDto.tone);
expect(result.formalityLevel).toBe(createDto.formalityLevel);
expect(result.systemPromptTemplate).toBe(createDto.systemPromptTemplate);
expect(result.isActive).toBe(true);
expect(result.isDefault).toBe(false);
expect(prisma.personality.create).toHaveBeenCalledWith({ expect(prisma.personality.create).toHaveBeenCalledWith({
data: { data: {
workspaceId: mockWorkspaceId, workspaceId: mockWorkspaceId,
name: createDto.name, name: createDto.name,
displayName: createDto.name, displayName: createDto.displayName,
description: createDto.description ?? null, description: createDto.description ?? null,
tone: createDto.tone, systemPrompt: createDto.systemPrompt,
formalityLevel: createDto.formalityLevel, temperature: createDto.temperature ?? null,
systemPrompt: createDto.systemPromptTemplate, maxTokens: createDto.maxTokens ?? null,
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
isDefault: false, isDefault: false,
isEnabled: true, isEnabled: true,
}, },
@@ -128,73 +101,68 @@ describe("PersonalitiesService", () => {
}); });
it("should throw ConflictException when name already exists", async () => { 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); await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
}); });
it("should unset other defaults when creating a new default personality", async () => { it("should unset other defaults when creating a new default personality", async () => {
const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true }; const createDefaultDto = { ...createDto, isDefault: true };
const otherDefault = { ...mockPrismaRecord, id: "other-id" }; // First call to findFirst checks for name conflict (should be null)
// Second call to findFirst finds the existing default personality
mockPrismaService.personality.findFirst mockPrismaService.personality.findFirst
.mockResolvedValueOnce(null) // name conflict check .mockResolvedValueOnce(null) // No name conflict
.mockResolvedValueOnce(otherDefault); // existing default lookup .mockResolvedValueOnce(mockPersonality); // Existing default
mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false }); mockPrismaService.personality.update.mockResolvedValue({
...mockPersonality,
isDefault: false,
});
mockPrismaService.personality.create.mockResolvedValue({ mockPrismaService.personality.create.mockResolvedValue({
...createdRecord, ...mockPersonality,
isDefault: true, ...createDefaultDto,
}); });
await service.create(mockWorkspaceId, createDefaultDto); await service.create(mockWorkspaceId, createDefaultDto);
expect(prisma.personality.update).toHaveBeenCalledWith({ expect(prisma.personality.update).toHaveBeenCalledWith({
where: { id: "other-id" }, where: { id: mockPersonalityId },
data: { isDefault: false }, data: { isDefault: false },
}); });
}); });
}); });
describe("findAll", () => { describe("findAll", () => {
it("should return mapped response list for a workspace", async () => { it("should return all personalities for a workspace", async () => {
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]); const mockPersonalities = [mockPersonality];
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
const result = await service.findAll(mockWorkspaceId); const result = await service.findAll(mockWorkspaceId);
expect(result).toHaveLength(1); expect(result).toEqual(mockPersonalities);
expect(result[0]).toEqual(mockResponse);
expect(prisma.personality.findMany).toHaveBeenCalledWith({ expect(prisma.personality.findMany).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId }, where: { workspaceId: mockWorkspaceId },
orderBy: [{ isDefault: "desc" }, { name: "asc" }], 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", () => { describe("findOne", () => {
it("should return a mapped personality response by id", async () => { it("should return a personality by id", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord); mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
const result = await service.findOne(mockWorkspaceId, mockPersonalityId); const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
expect(result).toEqual(mockResponse); expect(result).toEqual(mockPersonality);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({ expect(prisma.personality.findUnique).toHaveBeenCalledWith({
where: { id: mockPersonalityId, workspaceId: mockWorkspaceId }, where: {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
},
}); });
}); });
it("should throw NotFoundException when personality not found", async () => { 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( await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException NotFoundException
@@ -203,14 +171,17 @@ describe("PersonalitiesService", () => {
}); });
describe("findByName", () => { describe("findByName", () => {
it("should return a mapped personality response by name", async () => { it("should return a personality by name", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord); mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
const result = await service.findByName(mockWorkspaceId, "professional-assistant"); const result = await service.findByName(mockWorkspaceId, "professional-assistant");
expect(result).toEqual(mockResponse); expect(result).toEqual(mockPersonality);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({ 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", () => { describe("findDefault", () => {
it("should return the default personality", async () => { it("should return the default personality", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord); mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
const result = await service.findDefault(mockWorkspaceId); const result = await service.findDefault(mockWorkspaceId);
expect(result).toEqual(mockResponse); expect(result).toEqual(mockPersonality);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({ expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true }, where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
}); });
@@ -245,45 +216,41 @@ describe("PersonalitiesService", () => {
describe("update", () => { describe("update", () => {
const updateDto: UpdatePersonalityDto = { const updateDto: UpdatePersonalityDto = {
description: "Updated description", description: "Updated description",
tone: "formal", temperature: 0.9,
isActive: false,
}; };
it("should update a personality and return mapped response", async () => { it("should update a personality", async () => {
const updatedRecord = { mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
...mockPrismaRecord, mockPrismaService.personality.findFirst.mockResolvedValue(null);
description: updateDto.description, mockPrismaService.personality.update.mockResolvedValue({
tone: updateDto.tone, ...mockPersonality,
isEnabled: false, ...updateDto,
}; });
mockPrismaService.personality.findFirst
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
.mockResolvedValueOnce(null); // name conflict check (no dto.name here)
mockPrismaService.personality.update.mockResolvedValue(updatedRecord);
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto); const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
expect(result.description).toBe(updateDto.description); expect(result).toMatchObject(updateDto);
expect(result.tone).toBe(updateDto.tone); expect(prisma.personality.update).toHaveBeenCalledWith({
expect(result.isActive).toBe(false); where: { id: mockPersonalityId },
data: updateDto,
});
}); });
it("should throw NotFoundException when personality not found", async () => { 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( await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
NotFoundException NotFoundException
); );
}); });
it("should throw ConflictException when updating to an existing name", async () => { it("should throw ConflictException when updating to existing name", async () => {
const updateNameDto: UpdatePersonalityDto = { name: "existing-name" }; const updateNameDto = { name: "existing-name" };
const conflictRecord = { ...mockPrismaRecord, id: "different-id" }; mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.findFirst.mockResolvedValue({
mockPrismaService.personality.findFirst ...mockPersonality,
.mockResolvedValueOnce(mockPrismaRecord) // findOne check id: "different-id",
.mockResolvedValueOnce(conflictRecord); // name conflict });
await expect( await expect(
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto) service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
@@ -291,16 +258,14 @@ describe("PersonalitiesService", () => {
}); });
it("should unset other defaults when setting as default", async () => { it("should unset other defaults when setting as default", async () => {
const updateDefaultDto: UpdatePersonalityDto = { isDefault: true }; const updateDefaultDto = { isDefault: true };
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true }; const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
mockPrismaService.personality.findFirst mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
.mockResolvedValueOnce(mockPrismaRecord) // findOne check mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
mockPrismaService.personality.update mockPrismaService.personality.update
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) .mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
.mockResolvedValueOnce(updatedRecord); .mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto); await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
@@ -308,12 +273,16 @@ describe("PersonalitiesService", () => {
where: { id: "other-id" }, where: { id: "other-id" },
data: { isDefault: false }, data: { isDefault: false },
}); });
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
where: { id: mockPersonalityId },
data: updateDefaultDto,
});
}); });
}); });
describe("delete", () => { describe("delete", () => {
it("should delete a personality", async () => { it("should delete a personality", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord); mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
mockPrismaService.personality.delete.mockResolvedValue(undefined); mockPrismaService.personality.delete.mockResolvedValue(undefined);
await service.delete(mockWorkspaceId, mockPersonalityId); await service.delete(mockWorkspaceId, mockPersonalityId);
@@ -324,7 +293,7 @@ describe("PersonalitiesService", () => {
}); });
it("should throw NotFoundException when personality not found", async () => { 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( await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException NotFoundException
@@ -334,27 +303,30 @@ describe("PersonalitiesService", () => {
describe("setDefault", () => { describe("setDefault", () => {
it("should set a personality as default", async () => { it("should set a personality as default", async () => {
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true }; const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
const updatedRecord = { ...mockPrismaRecord, isDefault: true }; const updatedPersonality = { ...mockPersonality, isDefault: true };
mockPrismaService.personality.findFirst mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
.mockResolvedValueOnce(mockPrismaRecord) // findOne check mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
mockPrismaService.personality.update mockPrismaService.personality.update
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) .mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
.mockResolvedValueOnce(updatedRecord); .mockResolvedValueOnce(updatedPersonality); // Set new default
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId); const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
expect(result.isDefault).toBe(true); expect(result).toMatchObject({ isDefault: true });
expect(prisma.personality.update).toHaveBeenCalledWith({ expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
where: { id: "other-id" },
data: { isDefault: false },
});
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
where: { id: mockPersonalityId }, where: { id: mockPersonalityId },
data: { isDefault: true }, data: { isDefault: true },
}); });
}); });
it("should throw NotFoundException when personality not found", async () => { 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( await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException NotFoundException

View File

@@ -1,17 +1,10 @@
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common"; import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
import type { FormalityLevel, Personality } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
import type { CreatePersonalityDto } from "./dto/create-personality.dto"; import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
import type { UpdatePersonalityDto } from "./dto/update-personality.dto"; import { Personality } from "./entities/personality.entity";
import type { PersonalityQueryDto } from "./dto/personality-query.dto";
import type { PersonalityResponse } from "./entities/personality.entity";
/** /**
* Service for managing personality/assistant configurations. * Service for managing personality/assistant configurations
*
* Field mapping:
* Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate`
* Prisma `isEnabled` <-> API/frontend `isActive`
*/ */
@Injectable() @Injectable()
export class PersonalitiesService { export class PersonalitiesService {
@@ -19,30 +12,11 @@ export class PersonalitiesService {
constructor(private readonly prisma: PrismaService) {} 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 * Create a new personality
*/ */
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<PersonalityResponse> { async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
// Check for duplicate name within workspace // Check for duplicate name
const existing = await this.prisma.personality.findFirst({ const existing = await this.prisma.personality.findFirst({
where: { workspaceId, name: dto.name }, where: { workspaceId, name: dto.name },
}); });
@@ -51,7 +25,7 @@ export class PersonalitiesService {
throw new ConflictException(`Personality with name "${dto.name}" already exists`); 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) { if (dto.isDefault) {
await this.unsetOtherDefaults(workspaceId); await this.unsetOtherDefaults(workspaceId);
} }
@@ -60,43 +34,36 @@ export class PersonalitiesService {
data: { data: {
workspaceId, workspaceId,
name: dto.name, name: dto.name,
displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately displayName: dto.displayName,
description: dto.description ?? null, description: dto.description ?? null,
tone: dto.tone, systemPrompt: dto.systemPrompt,
formalityLevel: dto.formalityLevel, temperature: dto.temperature ?? null,
systemPrompt: dto.systemPromptTemplate, maxTokens: dto.maxTokens ?? null,
llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
isDefault: dto.isDefault ?? false, isDefault: dto.isDefault ?? false,
isEnabled: dto.isActive ?? true, isEnabled: dto.isEnabled ?? true,
}, },
}); });
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`); 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[]> { async findAll(workspaceId: string): Promise<Personality[]> {
const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId }; return this.prisma.personality.findMany({
where: { workspaceId },
if (query?.isActive !== undefined) {
where.isEnabled = query.isActive;
}
const personalities = await this.prisma.personality.findMany({
where,
orderBy: [{ isDefault: "desc" }, { name: "asc" }], orderBy: [{ isDefault: "desc" }, { name: "asc" }],
}); });
return personalities.map((p) => this.toResponse(p));
} }
/** /**
* Find a specific personality by ID * Find a specific personality by ID
*/ */
async findOne(workspaceId: string, id: string): Promise<PersonalityResponse> { async findOne(workspaceId: string, id: string): Promise<Personality> {
const personality = await this.prisma.personality.findFirst({ const personality = await this.prisma.personality.findUnique({
where: { id, workspaceId }, where: { id, workspaceId },
}); });
@@ -104,13 +71,13 @@ export class PersonalitiesService {
throw new NotFoundException(`Personality with ID ${id} not found`); 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({ const personality = await this.prisma.personality.findFirst({
where: { workspaceId, name }, where: { workspaceId, name },
}); });
@@ -119,13 +86,13 @@ export class PersonalitiesService {
throw new NotFoundException(`Personality with name "${name}" not found`); 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({ const personality = await this.prisma.personality.findFirst({
where: { workspaceId, isDefault: true, isEnabled: true }, where: { workspaceId, isDefault: true, isEnabled: true },
}); });
@@ -134,18 +101,14 @@ export class PersonalitiesService {
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`); throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
} }
return this.toResponse(personality); return personality;
} }
/** /**
* Update an existing personality * Update an existing personality
*/ */
async update( async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise<Personality> {
workspaceId: string, // Check existence
id: string,
dto: UpdatePersonalityDto
): Promise<PersonalityResponse> {
// Verify existence
await this.findOne(workspaceId, id); await this.findOne(workspaceId, id);
// Check for duplicate name if updating name // Check for duplicate name if updating name
@@ -164,43 +127,20 @@ export class PersonalitiesService {
await this.unsetOtherDefaults(workspaceId, id); 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({ const personality = await this.prisma.personality.update({
where: { id }, where: { id },
data: updateData, data: dto,
}); });
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`); this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
return this.toResponse(personality); return personality;
} }
/** /**
* Delete a personality * Delete a personality
*/ */
async delete(workspaceId: string, id: string): Promise<void> { async delete(workspaceId: string, id: string): Promise<void> {
// Verify existence // Check existence
await this.findOne(workspaceId, id); await this.findOne(workspaceId, id);
await this.prisma.personality.delete({ 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> { async setDefault(workspaceId: string, id: string): Promise<Personality> {
// Verify existence // Check existence
await this.findOne(workspaceId, id); await this.findOne(workspaceId, id);
// Unset other defaults // Unset other defaults
await this.unsetOtherDefaults(workspaceId, id); await this.unsetOtherDefaults(workspaceId, id);
// Set this one as default
const personality = await this.prisma.personality.update({ const personality = await this.prisma.personality.update({
where: { id }, where: { id },
data: { isDefault: true }, data: { isDefault: true },
}); });
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`); 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: { where: {
workspaceId, workspaceId,
isDefault: true, 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, workspaceId: string,
client: PrismaClient = this client: PrismaClient = this
): Promise<void> { ): Promise<void> {
// Use set_config() instead of SET LOCAL so values are safely parameterized. await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
// SET LOCAL with Prisma's tagged template produces invalid SQL (bind parameter $1 await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
// 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)`;
} }
/** /**
@@ -154,8 +151,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
* @param client - Optional Prisma client (uses 'this' if not provided) * @param client - Optional Prisma client (uses 'this' if not provided)
*/ */
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> { async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
await client.$executeRaw`SELECT set_config('app.current_user_id', '', true)`; await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
await client.$executeRaw`SELECT set_config('app.current_workspace_id', '', true)`; 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

@@ -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, Controller,
Get, Get,
Put, Put,
Patch,
Body, Body,
UseGuards, UseGuards,
Request, Request,
@@ -39,7 +38,7 @@ export class PreferencesController {
/** /**
* PUT /api/users/me/preferences * PUT /api/users/me/preferences
* Full replace of current user's preferences * Update current user's preferences
*/ */
@Put() @Put()
async updatePreferences( async updatePreferences(
@@ -54,22 +53,4 @@ export class PreferencesController {
return this.preferencesService.updatePreferences(userId, updatePreferencesDto); 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 { Logger } from "@nestjs/common";
import { Server, Socket } from "socket.io"; import { Server, Socket } from "socket.io";
import { AuthService } from "../auth/auth.service"; import { AuthService } from "../auth/auth.service";
import { getTrustedOrigins } from "../auth/auth.config";
import { PrismaService } from "../prisma/prisma.service"; import { PrismaService } from "../prisma/prisma.service";
interface AuthenticatedSocket extends Socket { interface AuthenticatedSocket extends Socket {
@@ -78,7 +77,7 @@ interface StepOutputData {
*/ */
@WSGateway({ @WSGateway({
cors: { cors: {
origin: getTrustedOrigins(), origin: process.env.WEB_URL ?? "http://localhost:3000",
credentials: true, credentials: true,
}, },
}) })
@@ -168,36 +167,17 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
} }
/** /**
* @description Extract authentication token from Socket.IO handshake. * @description Extract authentication token from Socket.IO handshake
*
* Checks sources in order:
* 1. handshake.auth.token — explicit token (e.g. from API clients)
* 2. handshake.headers.cookie — session cookie sent by browser via withCredentials
* 3. query.token — URL query parameter fallback
* 4. Authorization header — Bearer token fallback
*
* @param client - The socket client * @param client - The socket client
* @returns The token string or undefined if not found * @returns The token string or undefined if not found
*/ */
private extractTokenFromHandshake(client: Socket): string | undefined { 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; const authToken = client.handshake.auth.token as unknown;
if (typeof authToken === "string" && authToken.length > 0) { if (typeof authToken === "string" && authToken.length > 0) {
return authToken; 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 // Fallback: check query parameters
const queryToken = client.handshake.query.token as unknown; const queryToken = client.handshake.query.token as unknown;
if (typeof queryToken === "string" && queryToken.length > 0) { if (typeof queryToken === "string" && queryToken.length > 0) {
@@ -217,45 +197,6 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
return undefined; 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. * @description Handle client disconnect by leaving the workspace room.
* @param client - The socket client containing workspaceId in data. * @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 { WidgetsService } from "./widgets.service";
import { WidgetDataService } from "./widget-data.service"; import { WidgetDataService } from "./widget-data.service";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard } from "../common/guards/workspace.guard";
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto"; 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 * Controller for widget definition and data endpoints
* All endpoints require authentication; data endpoints also require workspace context * All endpoints require authentication
*/ */
@Controller("widgets") @Controller("widgets")
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@@ -43,9 +51,12 @@ export class WidgetsController {
* Get stat card widget data * Get stat card widget data
*/ */
@Post("data/stat-card") @Post("data/stat-card")
@UseGuards(WorkspaceGuard) async getStatCardData(@Request() req: AuthenticatedRequest, @Body() query: StatCardQueryDto) {
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) { const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
return this.widgetDataService.getStatCardData(req.workspace.id, query); 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 * Get chart widget data
*/ */
@Post("data/chart") @Post("data/chart")
@UseGuards(WorkspaceGuard) async getChartData(@Request() req: AuthenticatedRequest, @Body() query: ChartQueryDto) {
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) { const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
return this.widgetDataService.getChartData(req.workspace.id, query); 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 * Get list widget data
*/ */
@Post("data/list") @Post("data/list")
@UseGuards(WorkspaceGuard) async getListData(@Request() req: AuthenticatedRequest, @Body() query: ListQueryDto) {
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) { const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
return this.widgetDataService.getListData(req.workspace.id, query); 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 * Get calendar preview widget data
*/ */
@Post("data/calendar-preview") @Post("data/calendar-preview")
@UseGuards(WorkspaceGuard)
async getCalendarPreviewData( async getCalendarPreviewData(
@Request() req: RequestWithWorkspace, @Request() req: AuthenticatedRequest,
@Body() query: CalendarPreviewQueryDto @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 * Get active projects widget data
*/ */
@Post("data/active-projects") @Post("data/active-projects")
@UseGuards(WorkspaceGuard) async getActiveProjectsData(@Request() req: AuthenticatedRequest) {
async getActiveProjectsData(@Request() req: RequestWithWorkspace) { const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
return this.widgetDataService.getActiveProjectsData(req.workspace.id); 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) * Get agent chains widget data (active agent sessions)
*/ */
@Post("data/agent-chains") @Post("data/agent-chains")
@UseGuards(WorkspaceGuard) async getAgentChainsData(@Request() req: AuthenticatedRequest) {
async getAgentChainsData(@Request() req: RequestWithWorkspace) { const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
return this.widgetDataService.getAgentChainsData(req.workspace.id); 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", "name": "@mosaic/orchestrator",
"version": "0.0.20", "version": "0.0.6",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "nest start --watch", "dev": "nest start --watch",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@mosaic/web", "name": "@mosaic/web",
"version": "0.0.20", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "next build", "build": "next build",

View File

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

View File

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

View File

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

View File

@@ -14,7 +14,6 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials"; import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
import { useWorkspaceId } from "@/lib/hooks";
const ACTIVITY_ACTIONS = [ const ACTIVITY_ACTIONS = [
{ value: "CREDENTIAL_CREATED", label: "Created" }, { value: "CREDENTIAL_CREATED", label: "Created" },
@@ -40,17 +39,17 @@ export default function CredentialAuditPage(): React.ReactElement {
const [filters, setFilters] = useState<FilterState>({}); const [filters, setFilters] = useState<FilterState>({});
const [hasFilters, setHasFilters] = useState(false); const [hasFilters, setHasFilters] = useState(false);
const workspaceId = useWorkspaceId(); // TODO: Get workspace ID from context/auth
const workspaceId = "default-workspace-id"; // Placeholder
useEffect(() => { useEffect(() => {
if (!workspaceId) return; void loadLogs();
void loadLogs(workspaceId); }, [page, filters]);
}, [workspaceId, page, filters]);
async function loadLogs(wsId: string): Promise<void> { async function loadLogs(): Promise<void> {
try { try {
setIsLoading(true); setIsLoading(true);
const response = await fetchCredentialAuditLog(wsId, { const response = await fetchCredentialAuditLog(workspaceId, {
...filters, ...filters,
page, page,
limit, limit,

File diff suppressed because it is too large Load Diff

View File

@@ -1,383 +1,23 @@
"use client"; "use client";
import { useState, useEffect, type SyntheticEvent } from "react"; import { useState, useEffect } from "react";
import type { ReactElement } from "react";
import type { Domain } from "@mosaic/shared"; import type { Domain } from "@mosaic/shared";
import { DomainList } from "@/components/domains/DomainList"; import { DomainList } from "@/components/domains/DomainList";
import { fetchDomains, createDomain, deleteDomain } from "@/lib/api/domains"; import { fetchDomains, deleteDomain } from "@/lib/api/domains";
import type { CreateDomainDto } from "@/lib/api/domains";
import { useWorkspaceId } from "@/lib/hooks";
/* --------------------------------------------------------------------------- export default function DomainsPage(): React.ReactElement {
Slug generation helper
--------------------------------------------------------------------------- */
function generateSlug(name: string): string {
return name
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, "")
.replace(/\s+/g, "-")
.replace(/-+/g, "-")
.slice(0, 100);
}
/* ---------------------------------------------------------------------------
Create Domain Dialog
--------------------------------------------------------------------------- */
interface CreateDomainDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubmit: (data: CreateDomainDto) => Promise<void>;
isSubmitting: boolean;
}
function CreateDomainDialog({
open,
onOpenChange,
onSubmit,
isSubmitting,
}: CreateDomainDialogProps): ReactElement | null {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [slugTouched, setSlugTouched] = useState(false);
const [description, setDescription] = useState("");
const [formError, setFormError] = useState<string | null>(null);
function resetForm(): void {
setName("");
setSlug("");
setSlugTouched(false);
setDescription("");
setFormError(null);
}
function handleNameChange(value: string): void {
setName(value);
if (!slugTouched) {
setSlug(generateSlug(value));
}
}
function handleSlugChange(value: string): void {
setSlugTouched(true);
setSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
}
async function handleSubmit(e: SyntheticEvent): Promise<void> {
e.preventDefault();
setFormError(null);
const trimmedName = name.trim();
if (!trimmedName) {
setFormError("Domain name is required.");
return;
}
const trimmedSlug = slug.trim();
if (!trimmedSlug) {
setFormError("Slug is required.");
return;
}
if (!/^[a-z0-9-]+$/.test(trimmedSlug)) {
setFormError("Slug must contain only lowercase letters, numbers, and hyphens.");
return;
}
try {
const payload: CreateDomainDto = { name: trimmedName, slug: trimmedSlug };
const trimmedDesc = description.trim();
if (trimmedDesc) {
payload.description = trimmedDesc;
}
await onSubmit(payload);
resetForm();
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : "Failed to create domain.");
}
}
if (!open) return null;
return (
<div
role="dialog"
aria-modal="true"
aria-labelledby="create-domain-title"
style={{
position: "fixed",
inset: 0,
zIndex: 50,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{/* Backdrop */}
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
}}
onClick={() => {
if (!isSubmitting) {
resetForm();
onOpenChange(false);
}
}}
/>
{/* Dialog */}
<div
style={{
position: "relative",
background: "var(--surface, #fff)",
borderRadius: "8px",
border: "1px solid var(--border, #e5e7eb)",
padding: 24,
width: "100%",
maxWidth: 480,
zIndex: 1,
}}
>
<h2
id="create-domain-title"
style={{
fontSize: "1.125rem",
fontWeight: 600,
color: "var(--text, #111)",
margin: "0 0 8px",
}}
>
New Domain
</h2>
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
Domains help you organize tasks, projects, and events by life area.
</p>
<form
onSubmit={(e) => {
void handleSubmit(e);
}}
>
{/* Name */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="domain-name"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Name <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
</label>
<input
id="domain-name"
type="text"
value={name}
onChange={(e) => {
handleNameChange(e.target.value);
}}
placeholder="e.g. Personal Finance"
maxLength={255}
autoFocus
style={{
width: "100%",
padding: "8px 12px",
background: "var(--bg, #f9fafb)",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text, #111)",
fontSize: "0.9rem",
outline: "none",
boxSizing: "border-box",
}}
/>
</div>
{/* Slug */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="domain-slug"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Slug <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
</label>
<input
id="domain-slug"
type="text"
value={slug}
onChange={(e) => {
handleSlugChange(e.target.value);
}}
placeholder="e.g. personal-finance"
maxLength={100}
style={{
width: "100%",
padding: "8px 12px",
background: "var(--bg, #f9fafb)",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text, #111)",
fontSize: "0.9rem",
outline: "none",
boxSizing: "border-box",
fontFamily: "var(--mono, monospace)",
}}
/>
<p
style={{
fontSize: "0.75rem",
color: "var(--muted, #6b7280)",
margin: "4px 0 0",
}}
>
Lowercase letters, numbers, and hyphens only.
</p>
</div>
{/* Description */}
<div style={{ marginBottom: 16 }}>
<label
htmlFor="domain-description"
style={{
display: "block",
marginBottom: 6,
fontSize: "0.85rem",
fontWeight: 500,
color: "var(--text-2, #374151)",
}}
>
Description
</label>
<textarea
id="domain-description"
value={description}
onChange={(e) => {
setDescription(e.target.value);
}}
placeholder="A brief summary of this domain..."
rows={3}
maxLength={10000}
style={{
width: "100%",
padding: "8px 12px",
background: "var(--bg, #f9fafb)",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text, #111)",
fontSize: "0.9rem",
outline: "none",
resize: "vertical",
fontFamily: "inherit",
boxSizing: "border-box",
}}
/>
</div>
{/* Form error */}
{formError !== null && (
<p
style={{
color: "var(--danger, #ef4444)",
fontSize: "0.85rem",
margin: "0 0 12px",
}}
>
{formError}
</p>
)}
{/* Buttons */}
<div
style={{
display: "flex",
justifyContent: "flex-end",
gap: 8,
marginTop: 8,
}}
>
<button
type="button"
onClick={() => {
resetForm();
onOpenChange(false);
}}
disabled={isSubmitting}
style={{
padding: "8px 16px",
background: "transparent",
border: "1px solid var(--border, #d1d5db)",
borderRadius: "6px",
color: "var(--text-2, #374151)",
fontSize: "0.85rem",
cursor: "pointer",
}}
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim() || !slug.trim()}
style={{
padding: "8px 16px",
background: "var(--primary, #111827)",
border: "none",
borderRadius: "6px",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: isSubmitting || !name.trim() || !slug.trim() ? "not-allowed" : "pointer",
opacity: isSubmitting || !name.trim() || !slug.trim() ? 0.6 : 1,
}}
>
{isSubmitting ? "Creating..." : "Create Domain"}
</button>
</div>
</form>
</div>
</div>
);
}
/* ---------------------------------------------------------------------------
Domains Page
--------------------------------------------------------------------------- */
export default function DomainsPage(): ReactElement {
const workspaceId = useWorkspaceId();
const [domains, setDomains] = useState<Domain[]>([]); const [domains, setDomains] = useState<Domain[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Create dialog state
const [createOpen, setCreateOpen] = useState(false);
const [isCreating, setIsCreating] = useState(false);
useEffect(() => { useEffect(() => {
if (!workspaceId) {
setIsLoading(false);
return;
}
void loadDomains(); void loadDomains();
}, [workspaceId]); }, []);
async function loadDomains(): Promise<void> { async function loadDomains(): Promise<void> {
try { try {
setIsLoading(true); setIsLoading(true);
const response = await fetchDomains(undefined, workspaceId ?? undefined); const response = await fetchDomains();
setDomains(response.data); setDomains(response.data);
setError(null); setError(null);
} catch (err) { } 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 // TODO: Open edit modal/form
console.log("Edit domain:", domain);
} }
async function handleDelete(domain: Domain): Promise<void> { async function handleDelete(domain: Domain): Promise<void> {
@@ -397,26 +38,13 @@ export default function DomainsPage(): ReactElement {
} }
try { try {
await deleteDomain(domain.id, workspaceId ?? undefined); await deleteDomain(domain.id);
await loadDomains(); await loadDomains();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete domain"); 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 ( return (
<div className="max-w-6xl mx-auto p-6"> <div className="max-w-6xl mx-auto p-6">
<div className="mb-6"> <div className="mb-6">
@@ -432,7 +60,7 @@ export default function DomainsPage(): ReactElement {
<button <button
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800" className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
onClick={() => { onClick={() => {
setCreateOpen(true); console.log("TODO: Open create modal");
}} }}
> >
Create Domain Create Domain
@@ -445,13 +73,6 @@ export default function DomainsPage(): ReactElement {
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
/> />
<CreateDomainDialog
open={createOpen}
onOpenChange={setCreateOpen}
onSubmit={handleCreate}
isSubmitting={isCreating}
/>
</div> </div>
); );
} }

View File

@@ -1,10 +1,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useState } from "react";
import type { ReactElement, ReactNode } 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"; import Link from "next/link";
interface CategoryConfig { interface CategoryConfig {
@@ -14,7 +11,6 @@ interface CategoryConfig {
accent: string; accent: string;
iconBg: string; iconBg: string;
icon: ReactNode; icon: ReactNode;
adminOnly?: boolean;
} }
interface SettingsCategoryCardProps { interface SettingsCategoryCardProps {
@@ -200,57 +196,6 @@ const categories: CategoryConfig[] = [
</svg> </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", title: "Workspaces",
description: description:
@@ -282,30 +227,7 @@ const categories: CategoryConfig[] = [
}, },
]; ];
const ADMIN_ROLES: WorkspaceMemberRole[] = [WorkspaceMemberRole.OWNER, WorkspaceMemberRole.ADMIN];
export default function SettingsPage(): ReactElement { 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 ( return (
<div className="max-w-6xl mx-auto p-6"> <div className="max-w-6xl mx-auto p-6">
{/* Page header */} {/* Page header */}
@@ -333,7 +255,7 @@ export default function SettingsPage(): ReactElement {
{/* Category grid */} {/* Category grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{visibleCategories.map((category) => ( {categories.map((category) => (
<SettingsCategoryCard key={category.href} category={category} /> <SettingsCategoryCard key={category.href} category={category} />
))} ))}
</div> </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"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useState } from "react";
import { useParams } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import { WorkspaceSettings } from "@/components/workspace/WorkspaceSettings";
import { MemberList } from "@/components/workspace/MemberList"; 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 { WorkspaceMemberRole } from "@mosaic/shared";
import { import type { Workspace, WorkspaceMemberWithUser } from "@mosaic/shared";
fetchWorkspaceMembers, import Link from "next/link";
fetchUserWorkspaces,
updateWorkspaceMemberRole,
removeWorkspaceMember,
type WorkspaceMemberEntry,
type UserWorkspace,
} from "@/lib/api/workspaces";
import type { WorkspaceMemberWithUser } from "@/components/workspace/MemberList";
function getErrorMessage(error: unknown, fallback: string): string { interface WorkspaceDetailPageProps {
if (error instanceof Error) return error.message; params: {
return fallback; id: string;
}
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(),
},
}; };
} }
export default function WorkspaceDetailPage(): React.ReactElement { // Mock data - TODO: Replace with real API calls
const params = useParams<{ id: string }>(); const mockWorkspace: Workspace = {
const workspaceId = params.id; id: "ws-1",
const { user: authUser } = useAuth(); 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 mockMembers: WorkspaceMemberWithUser[] = [
const [members, setMembers] = useState<WorkspaceMemberEntry[]>([]); {
const [isLoading, setIsLoading] = useState(true); workspaceId: "ws-1",
const [error, setError] = useState<string | null>(null); userId: "user-1",
role: WorkspaceMemberRole.OWNER,
const load = useCallback(async (): Promise<void> => { joinedAt: new Date("2024-01-15"),
setIsLoading(true); user: {
setError(null); id: "user-1",
try { email: "owner@example.com",
const [workspaces, memberList] = await Promise.all([ name: "John Doe",
fetchUserWorkspaces(), emailVerified: true,
fetchWorkspaceMembers(workspaceId), image: null,
]); authProviderId: null,
const ws = workspaces.find((w) => w.id === workspaceId) ?? null; preferences: {},
setWorkspace(ws); createdAt: new Date("2024-01-15"),
setMembers(memberList); updatedAt: new Date("2024-01-15"),
} 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();
}, },
[workspaceId, load] },
); {
workspaceId: "ws-1",
const handleRemove = useCallback( userId: "user-2",
async (userId: string): Promise<void> => { role: WorkspaceMemberRole.ADMIN,
await removeWorkspaceMember(workspaceId, userId); joinedAt: new Date("2024-01-16"),
await load(); 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) { export default function WorkspaceDetailPage({
return ( params,
<div className="p-8 text-center text-gray-500" role="status" aria-label="Loading workspace"> }: WorkspaceDetailPageProps): React.JSX.Element {
Loading workspace const router = useRouter();
</div> 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) { const handleRemoveMember = async (userId: string): Promise<void> => {
return ( // TODO: Replace with real API call
<div className="p-8"> console.log("Removing member:", userId);
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-red-700" role="alert"> await new Promise((resolve) => setTimeout(resolve, 500));
<p className="font-medium">Failed to load workspace</p> setMembers(members.filter((member) => member.userId !== userId));
<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 currentUserId = authUser?.id ?? ""; const handleInviteMember = async (email: string, role: WorkspaceMemberRole): Promise<void> => {
const currentMember = members.find((m) => m.userId === currentUserId); // TODO: Replace with real API call
const currentUserRole = currentMember?.role ?? workspace?.role ?? WorkspaceMemberRole.MEMBER; console.log("Inviting member:", { email, role, workspaceId: params.id });
const ownerId = await new Promise((resolve) => setTimeout(resolve, 1000));
members.find((m) => m.role === WorkspaceMemberRole.OWNER)?.userId ?? workspace?.ownerId ?? ""; // In real implementation, this would send an invitation email
};
return ( return (
<div className="p-8 max-w-3xl"> <main className="container mx-auto px-4 py-8 max-w-5xl">
<div className="mb-6"> <div className="mb-8">
<Link href="/settings/workspaces" className="text-sm text-gray-500 hover:text-gray-700"> <div className="flex items-center justify-between mb-2">
Back to Workspaces <h1 className="text-3xl font-bold text-gray-900">{workspace.name}</h1>
</Link> <Link href="/settings/workspaces" className="text-sm text-blue-600 hover:text-blue-700">
<h1 className="mt-2 text-2xl font-bold text-gray-900">{workspace?.name ?? "Workspace"}</h1> Back to Workspaces
</Link>
</div>
<p className="text-gray-600">Manage workspace settings and team members</p>
</div> </div>
<MemberList <div className="space-y-6">
members={members.map(toMemberWithUser)} {/* Workspace Settings */}
currentUserId={currentUserId} <WorkspaceSettings
currentUserRole={currentUserRole} workspace={workspace}
workspaceOwnerId={ownerId} userRole={currentUserRole}
onRoleChange={handleRoleChange} onUpdate={handleUpdateWorkspace}
onRemove={handleRemove} onDelete={handleDeleteWorkspace}
/> />
</div>
{/* 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 { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen } 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";
// Mock next/link
vi.mock("next/link", () => ({ vi.mock("next/link", () => ({
default: function LinkMock({ default: ({ children, href }: { children: React.ReactNode; href: string }): React.JSX.Element => (
children, <a href={href}>{children}</a>
href, ),
}: {
children: ReactNode;
href: string;
}): ReactElement {
return <a href={href}>{children}</a>;
},
})); }));
// Mock the WorkspaceCard component
vi.mock("@/components/workspace/WorkspaceCard", () => ({ vi.mock("@/components/workspace/WorkspaceCard", () => ({
WorkspaceCard: function WorkspaceCardMock({ WorkspaceCard: (): React.JSX.Element => <div data-testid="workspace-card">WorkspaceCard</div>,
workspace,
userRole,
memberCount,
}: {
workspace: { name: string };
userRole: WorkspaceMemberRole;
memberCount: number;
}): ReactElement {
return (
<div data-testid="workspace-card">
{workspace.name} | {userRole} | {String(memberCount)}
</div>
);
},
})); }));
vi.mock("@/lib/api/workspaces", () => ({ describe("WorkspacesPage", (): void => {
fetchUserWorkspaces: vi.fn(), // Note: NODE_ENV is "test" during test runs, which triggers the Coming Soon view
createWorkspace: vi.fn(), // This tests the production-like behavior where mock data is hidden
}));
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]);
it("should render the Coming Soon view in non-development environments", async (): Promise<void> => {
const { default: WorkspacesPage } = await import("./page");
render(<WorkspacesPage />); render(<WorkspacesPage />);
expect(screen.getByText("Loading workspaces...")).toBeInTheDocument(); // In test mode (non-development), should show Coming Soon
expect(screen.getByText("Coming Soon")).toBeInTheDocument();
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); expect(screen.getByText("Workspace Management")).toBeInTheDocument();
expect(screen.getByTestId("workspace-card")).toHaveTextContent("Personal Workspace");
expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(1);
}); });
it("shows fetch errors in the UI", async () => { it("should display appropriate description for workspace feature", async (): Promise<void> => {
fetchUserWorkspacesMock.mockRejectedValue(new Error("Unable to load workspaces")); const { default: WorkspacesPage } = await import("./page");
render(<WorkspacesPage />); 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 () => { it("should not render mock workspace data in Coming Soon view", async (): Promise<void> => {
fetchUserWorkspacesMock.mockResolvedValueOnce([baseWorkspace]).mockResolvedValueOnce([ const { default: WorkspacesPage } = await import("./page");
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();
render(<WorkspacesPage />); render(<WorkspacesPage />);
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); // Should not show workspace cards or create form in non-development mode
expect(screen.queryByTestId("workspace-card")).not.toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "New Workspace"); expect(screen.queryByText("Create New Workspace")).not.toBeInTheDocument();
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();
}); });
it("shows create errors in the UI", async () => { it("should include link back to settings", async (): Promise<void> => {
fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]); const { default: WorkspacesPage } = await import("./page");
createWorkspaceMock.mockRejectedValue(new Error("Workspace creation failed"));
const user = userEvent.setup();
render(<WorkspacesPage />); render(<WorkspacesPage />);
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); const link = screen.getByRole("link", { name: /back to settings/i });
expect(link).toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Bad Workspace"); expect(link).toHaveAttribute("href", "/settings");
await user.click(screen.getByRole("button", { name: "Create Workspace" }));
expect(await screen.findByText("Workspace creation failed")).toBeInTheDocument();
}); });
}); });

View File

@@ -1,74 +1,72 @@
"use client"; "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 { 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"; import Link from "next/link";
function getErrorMessage(error: unknown, fallback: string): string { // Check if we're in development mode
if (error instanceof Error) { const isDevelopment = process.env.NODE_ENV === "development";
return error.message;
}
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 * Workspaces Page Content - Development Only
* Fetches and creates workspaces through the real API. * Shows mock workspace data for development purposes
*/ */
export default function WorkspacesPage(): ReactElement { function WorkspacesPageContent(): ReactElement {
const [workspaces, setWorkspaces] = useState<UserWorkspace[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState(""); const [newWorkspaceName, setNewWorkspaceName] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const loadWorkspaces = useCallback(async (): Promise<void> => { // TODO: Replace with real API call
setIsLoading(true); 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 handleCreateWorkspace = async (e: React.SyntheticEvent<HTMLFormElement>): Promise<void> => {
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> => {
e.preventDefault(); e.preventDefault();
if (!newWorkspaceName.trim()) return;
const workspaceName = newWorkspaceName.trim();
if (!workspaceName) return;
setIsCreating(true); setIsCreating(true);
setCreateError(null);
try { 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(""); setNewWorkspaceName("");
await loadWorkspaces(); } catch (_error) {
} catch (error) { console.error("Failed to create workspace:", _error);
setCreateError(getErrorMessage(error, "Failed to create workspace")); alert("Failed to create workspace");
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
@@ -108,27 +106,14 @@ export default function WorkspacesPage(): ReactElement {
{isCreating ? "Creating..." : "Create Workspace"} {isCreating ? "Creating..." : "Create Workspace"}
</button> </button>
</form> </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> </div>
{/* Workspace List */} {/* Workspace List */}
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-xl font-semibold text-gray-900"> <h2 className="text-xl font-semibold text-gray-900">
Your Workspaces ({isLoading ? "..." : workspacesWithRoles.length}) Your Workspaces ({workspacesWithRoles.length})
</h2> </h2>
{loadError !== null ? ( {workspacesWithRoles.length === 0 ? (
<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 ? (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center"> <div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<svg <svg
className="mx-auto h-12 w-12 text-gray-400 mb-4" className="mx-auto h-12 w-12 text-gray-400 mb-4"
@@ -162,3 +147,26 @@ export default function WorkspacesPage(): ReactElement {
</main> </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 = { export const metadata: Metadata = {
title: "Mosaic Stack", title: "Mosaic Stack",
description: "Mosaic Stack Web Application", description: "Mosaic Stack Web Application",
icons: {
icon: "/favicon.ico",
},
}; };
const outfit = Outfit({ const outfit = Outfit({

View File

@@ -2,25 +2,124 @@
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { useParams } from "next/navigation"; import { useState } from "react";
import { ComingSoon } from "@/components/ui/ComingSoon"; import { useParams, useRouter } from "next/navigation";
import { TeamSettings } from "@/components/team/TeamSettings";
import { TeamMemberList } from "@/components/team/TeamMemberList";
import { mockTeamWithMembers } from "@/lib/api/teams";
import type { User } from "@mosaic/shared";
import type { TeamMemberRole } from "@mosaic/shared";
import Link from "next/link"; import Link from "next/link";
// Mock available users for adding to team
const mockAvailableUsers: User[] = [
{
id: "user-3",
email: "alice@example.com",
name: "Alice Johnson",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
createdAt: new Date("2026-01-17"),
updatedAt: new Date("2026-01-17"),
},
{
id: "user-4",
email: "bob@example.com",
name: "Bob Wilson",
emailVerified: true,
image: null,
authProviderId: null,
preferences: {},
createdAt: new Date("2026-01-18"),
updatedAt: new Date("2026-01-18"),
},
];
export default function TeamDetailPage(): ReactElement { export default function TeamDetailPage(): ReactElement {
const params = useParams(); const params = useParams();
const router = useRouter();
const workspaceId = params.id as string; const workspaceId = params.id as string;
// const teamId = params.teamId as string; // Will be used for API calls
// TODO: Replace with real API call when backend is ready
// const { data: team, isLoading } = useQuery({
// queryKey: ["team", workspaceId, params.teamId],
// queryFn: () => fetchTeam(workspaceId, params.teamId as string),
// });
const [team] = useState(mockTeamWithMembers);
const [isLoading] = useState(false);
const handleUpdateTeam = (data: { name?: string; description?: string }): Promise<void> => {
// TODO: Replace with real API call
// await updateTeam(workspaceId, teamId, data);
console.log("Updating team:", data);
// TODO: Refetch team data
return Promise.resolve();
};
const handleDeleteTeam = (): Promise<void> => {
// TODO: Replace with real API call
// await deleteTeam(workspaceId, teamId);
console.log("Deleting team");
// Navigate back to teams list
router.push(`/settings/workspaces/${workspaceId}/teams`);
return Promise.resolve();
};
const handleAddMember = (userId: string, role?: TeamMemberRole): Promise<void> => {
// TODO: Replace with real API call
// await addTeamMember(workspaceId, teamId, { userId, role });
console.log("Adding member:", { userId, role });
// TODO: Refetch team data
return Promise.resolve();
};
const handleRemoveMember = (userId: string): Promise<void> => {
// TODO: Replace with real API call
// await removeTeamMember(workspaceId, teamId, userId);
console.log("Removing member:", userId);
// TODO: Refetch team data
return Promise.resolve();
};
if (isLoading) {
return (
<main className="container mx-auto px-4 py-8">
<div className="flex justify-center items-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
<span className="ml-3 text-gray-600">Loading team...</span>
</div>
</main>
);
}
return ( return (
<ComingSoon <main className="container mx-auto px-4 py-8">
feature="Team Details" <div className="mb-8">
description="Team member management is being migrated to live API-backed data." <Link
> href={`/settings/workspaces/${workspaceId}/teams`}
<Link className="text-blue-600 hover:text-blue-700 text-sm mb-2 inline-block"
href={`/settings/workspaces/${workspaceId}/teams`} >
className="text-sm text-blue-600 hover:text-blue-700" Back to Teams
> </Link>
{"<-"} Back to Teams <h1 className="text-3xl font-bold text-gray-900">{team.name}</h1>
</Link> {team.description && <p className="text-gray-600 mt-2">{team.description}</p>}
</ComingSoon> </div>
<div className="space-y-6">
<TeamSettings team={team} onUpdate={handleUpdateTeam} onDelete={handleDeleteTeam} />
<TeamMemberList
members={team.members}
onAddMember={handleAddMember}
onRemoveMember={handleRemoveMember}
availableUsers={mockAvailableUsers}
/>
</div>
</main>
); );
} }

View File

@@ -2,90 +2,63 @@
import type { ReactElement } from "react"; import type { ReactElement } from "react";
import { useCallback, useEffect, useState } from "react"; import { useState } from "react";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { TeamCard } from "@/components/team/TeamCard"; import { TeamCard } from "@/components/team/TeamCard";
import { ComingSoon } from "@/components/ui/ComingSoon"; import { ComingSoon } from "@/components/ui/ComingSoon";
import { Button, Input, Modal } from "@mosaic/ui"; import { Button, Input, Modal } from "@mosaic/ui";
import { createTeam, fetchTeams, type CreateTeamDto, type TeamRecord } from "@/lib/api/teams"; import { mockTeams } from "@/lib/api/teams";
import Link from "next/link"; import Link from "next/link";
// Check if we're in development mode
const isDevelopment = process.env.NODE_ENV === "development"; const isDevelopment = process.env.NODE_ENV === "development";
function getErrorMessage(error: unknown, fallback: string): string { /**
if (error instanceof Error) { * Teams Page Content - Development Only
return error.message; * Shows mock team data for development purposes
} */
return fallback;
}
function TeamsPageContent(): ReactElement { function TeamsPageContent(): ReactElement {
const params = useParams(); const params = useParams();
const workspaceId = params.id as string; const workspaceId = params.id as string;
const [teams, setTeams] = useState<TeamRecord[]>([]); // TODO: Replace with real API call when backend is ready
const [isLoading, setIsLoading] = useState(true); // const { data: teams, isLoading } = useQuery({
const [loadError, setLoadError] = useState<string | null>(null); // queryKey: ["teams", workspaceId],
// queryFn: () => fetchTeams(workspaceId),
// });
const [teams] = useState(mockTeams);
const [isLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [newTeamName, setNewTeamName] = useState(""); const [newTeamName, setNewTeamName] = useState("");
const [newTeamDescription, setNewTeamDescription] = useState(""); const [newTeamDescription, setNewTeamDescription] = useState("");
const [createError, setCreateError] = useState<string | null>(null);
const loadTeams = useCallback(async (): Promise<void> => { const handleCreateTeam = (): void => {
setIsLoading(true); if (!newTeamName.trim()) return;
try {
const data = await fetchTeams(workspaceId);
setTeams(data);
setLoadError(null);
} catch (error) {
setLoadError(getErrorMessage(error, "Failed to load teams"));
} finally {
setIsLoading(false);
}
}, [workspaceId]);
useEffect(() => {
void loadTeams();
}, [loadTeams]);
const handleCreateTeam = async (): Promise<void> => {
const teamName = newTeamName.trim();
if (!teamName) return;
setIsCreating(true); setIsCreating(true);
setCreateError(null);
try { try {
const description = newTeamDescription.trim(); // TODO: Replace with real API call
const dto: CreateTeamDto = { // await createTeam(workspaceId, {
name: teamName, // name: newTeamName,
}; // description: newTeamDescription || undefined,
if (description.length > 0) { // });
dto.description = description;
}
await createTeam(dto, workspaceId);
// Reset form
setNewTeamName(""); setNewTeamName("");
setNewTeamDescription(""); setNewTeamDescription("");
setShowCreateModal(false); setShowCreateModal(false);
await loadTeams();
} catch (error) { // TODO: Refresh teams list
setCreateError(getErrorMessage(error, "Failed to create team")); } catch (_error) {
console.error("Failed to create team:", _error);
alert("Failed to create team. Please try again.");
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
}; };
const teamsForCards = teams.map((team) => ({
...team,
createdAt: new Date(team.createdAt),
updatedAt: new Date(team.updatedAt),
}));
if (isLoading) { if (isLoading) {
return ( return (
<main className="container mx-auto px-4 py-8"> <main className="container mx-auto px-4 py-8">
@@ -107,7 +80,6 @@ function TeamsPageContent(): ReactElement {
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
setCreateError(null);
setShowCreateModal(true); setShowCreateModal(true);
}} }}
> >
@@ -115,11 +87,7 @@ function TeamsPageContent(): ReactElement {
</Button> </Button>
</div> </div>
{loadError !== null ? ( {teams.length === 0 ? (
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700">
{loadError}
</div>
) : teamsForCards.length === 0 ? (
<div className="text-center p-12 bg-gray-50 rounded-lg"> <div className="text-center p-12 bg-gray-50 rounded-lg">
<p className="text-lg text-gray-500 mb-4">No teams yet</p> <p className="text-lg text-gray-500 mb-4">No teams yet</p>
<p className="text-sm text-gray-400 mb-6"> <p className="text-sm text-gray-400 mb-6">
@@ -128,7 +96,6 @@ function TeamsPageContent(): ReactElement {
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
setCreateError(null);
setShowCreateModal(true); setShowCreateModal(true);
}} }}
> >
@@ -137,12 +104,13 @@ function TeamsPageContent(): ReactElement {
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{teamsForCards.map((team) => ( {teams.map((team) => (
<TeamCard key={team.id} team={team} workspaceId={workspaceId} /> <TeamCard key={team.id} team={team} workspaceId={workspaceId} />
))} ))}
</div> </div>
)} )}
{/* Create Team Modal */}
{showCreateModal && ( {showCreateModal && (
<Modal <Modal
isOpen={showCreateModal} isOpen={showCreateModal}
@@ -175,11 +143,6 @@ function TeamsPageContent(): ReactElement {
fullWidth fullWidth
disabled={isCreating} disabled={isCreating}
/> />
{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 gap-2 justify-end pt-4"> <div className="flex gap-2 justify-end pt-4">
<Button <Button
variant="ghost" variant="ghost"
@@ -192,7 +155,7 @@ function TeamsPageContent(): ReactElement {
</Button> </Button>
<Button <Button
variant="primary" variant="primary"
onClick={() => void handleCreateTeam()} onClick={handleCreateTeam}
disabled={!newTeamName.trim() || isCreating} disabled={!newTeamName.trim() || isCreating}
> >
{isCreating ? "Creating..." : "Create Team"} {isCreating ? "Creating..." : "Create Team"}
@@ -205,7 +168,12 @@ function TeamsPageContent(): ReactElement {
); );
} }
/**
* Teams Page Entry Point
* Shows development content or Coming Soon based on environment
*/
export default function TeamsPage(): ReactElement { export default function TeamsPage(): ReactElement {
// In production, show Coming Soon placeholder
if (!isDevelopment) { if (!isDevelopment) {
return ( return (
<ComingSoon <ComingSoon
@@ -219,5 +187,6 @@ export default function TeamsPage(): ReactElement {
); );
} }
// In development, show the full page with mock data
return <TeamsPageContent />; return <TeamsPageContent />;
} }

View File

@@ -5,7 +5,6 @@ import { useAuth } from "@/lib/auth/auth-context";
import { useChat } from "@/hooks/useChat"; import { useChat } from "@/hooks/useChat";
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands"; import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
import { useWebSocket } from "@/hooks/useWebSocket"; import { useWebSocket } from "@/hooks/useWebSocket";
import { useWorkspaceId } from "@/lib/hooks";
import { MessageList } from "./MessageList"; import { MessageList } from "./MessageList";
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput"; import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
import { ChatEmptyState } from "./ChatEmptyState"; import { ChatEmptyState } from "./ChatEmptyState";
@@ -90,11 +89,7 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
...(initialProjectId !== undefined && { projectId: initialProjectId }), ...(initialProjectId !== undefined && { projectId: initialProjectId }),
}); });
// Read workspace ID from localStorage (set by auth-context after session check). const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
// Cookie-based auth (withCredentials) handles authentication, so no explicit
// token is needed here — pass an empty string as the token placeholder.
const workspaceId = useWorkspaceId() ?? "";
const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {});
const { isCommand, executeCommand } = useOrchestratorCommands(); const { isCommand, executeCommand } = useOrchestratorCommands();

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