Compare commits
100 Commits
v0.0.15
...
feat/ms21-
| Author | SHA1 | Date | |
|---|---|---|---|
| b174ba4f14 | |||
| c939a541a7 | |||
| 895ea7fd14 | |||
| e93e7ffaa9 | |||
| 307639eca0 | |||
| 31814f181a | |||
| 5cd6b8622d | |||
| 20c9e68e1b | |||
| 127bf61fe2 | |||
| f99107fbfc | |||
| 5b782bafc9 | |||
| 85d3f930f3 | |||
| 0e6734bdae | |||
| 5bcaaeddd9 | |||
| 676a2a288b | |||
| ac16d6ed88 | |||
| 8388d49786 | |||
| 20f914ea85 | |||
| 1b84741f1a | |||
| ffc10c9a45 | |||
| 62d9ac0e5a | |||
| 8098504fb8 | |||
| 128431ba58 | |||
| d2c51eda91 | |||
| 78b643a945 | |||
| f93503ebcf | |||
| c0e679ab7c | |||
| 6ac63fe755 | |||
| 1667f28d71 | |||
| 66fe475fa1 | |||
| d39ab6aafc | |||
| 147e8ac574 | |||
| c38bfae16c | |||
| 36b4d8323d | |||
| 833662a64f | |||
| b3922e1d5b | |||
| 78b71a0ecc | |||
| dd0568cf15 | |||
| 8964226163 | |||
| 11f22a7e96 | |||
| edcff6a0e0 | |||
| e3cba37e8c | |||
| 21bf7e050f | |||
| 83d5aee53a | |||
| cc5b108b2f | |||
| 5ed0a859da | |||
| bf299bb672 | |||
| ad99cb9a03 | |||
| d05b870f08 | |||
| 1aaf5618ce | |||
| 9b2520ce1f | |||
| b110c469c4 | |||
| 859dcfc4b7 | |||
| 13aa52aa53 | |||
| 417c6ab49c | |||
| 8128eb7fbe | |||
| 7de0e734b0 | |||
| 6290fc3d53 | |||
| 9f4de1682f | |||
| 374ca7ace3 | |||
| 72c64d2eeb | |||
| 5f6c520a98 | |||
| 9a7673bea2 | |||
| 91934b9933 | |||
| 7f89682946 | |||
| 8b4c565f20 | |||
| d5ecc0b107 | |||
| a81c4a5edd | |||
| ff5a09c3fb | |||
| f93fa60fff | |||
| cc56f2cbe1 | |||
| f9cccd6965 | |||
| 90c3bbccdf | |||
| 79286e98c6 | |||
| cfd1def4a9 | |||
| f435d8e8c6 | |||
| 3d78b09064 | |||
| a7955b9b32 | |||
| 372cc100cc | |||
| 37cf813b88 | |||
| 3d5b50af11 | |||
| f30c2f790c | |||
| 05b1a93ccb | |||
| a78a8b88e1 | |||
| 172ed1d40f | |||
| ee2ddfc8b8 | |||
| 5a6d00a064 | |||
| ffda74ec12 | |||
| f97be2e6a3 | |||
| 97606713b5 | |||
| d0c720e6da | |||
| 64e817cfb8 | |||
| cd5c2218c8 | |||
| f643d2bc04 | |||
| 8957904ea9 | |||
| 458cac7cdd | |||
| 7581d26567 | |||
| 07f5225a76 | |||
| 7c55464d54 | |||
| ea1620fa7a |
30
.env.example
30
.env.example
@@ -79,7 +79,7 @@ OIDC_CLIENT_ID=your-client-id-here
|
||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||
# Redirect URI must match what's configured in Authentik
|
||||
# Development: http://localhost:3001/auth/oauth2/callback/authentik
|
||||
# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
|
||||
# Production: https://mosaic-api.woltje.com/auth/oauth2/callback/authentik
|
||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||
|
||||
# Authentik PostgreSQL Database
|
||||
@@ -314,17 +314,19 @@ COORDINATOR_ENABLED=true
|
||||
# TTL is in seconds, limits are per TTL window
|
||||
|
||||
# Global rate limit (applies to all endpoints unless overridden)
|
||||
RATE_LIMIT_TTL=60 # Time window in seconds
|
||||
RATE_LIMIT_GLOBAL_LIMIT=100 # Requests per window
|
||||
# Time window in seconds
|
||||
RATE_LIMIT_TTL=60
|
||||
# Requests per window
|
||||
RATE_LIMIT_GLOBAL_LIMIT=100
|
||||
|
||||
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch)
|
||||
RATE_LIMIT_WEBHOOK_LIMIT=60 # Requests per minute
|
||||
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute
|
||||
RATE_LIMIT_WEBHOOK_LIMIT=60
|
||||
|
||||
# Coordinator endpoints (/coordinator/*)
|
||||
RATE_LIMIT_COORDINATOR_LIMIT=100 # Requests per minute
|
||||
# Coordinator endpoints (/coordinator/*) — requests per minute
|
||||
RATE_LIMIT_COORDINATOR_LIMIT=100
|
||||
|
||||
# Health check endpoints (/coordinator/health)
|
||||
RATE_LIMIT_HEALTH_LIMIT=300 # Requests per minute (higher for monitoring)
|
||||
# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring)
|
||||
RATE_LIMIT_HEALTH_LIMIT=300
|
||||
|
||||
# Storage backend for rate limiting (redis or memory)
|
||||
# redis: Uses Valkey for distributed rate limiting (recommended for production)
|
||||
@@ -359,17 +361,17 @@ RATE_LIMIT_STORAGE=redis
|
||||
# a single workspace.
|
||||
MATRIX_HOMESERVER_URL=http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN=
|
||||
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.example.com
|
||||
MATRIX_SERVER_NAME=matrix.example.com
|
||||
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.example.com
|
||||
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
|
||||
MATRIX_SERVER_NAME=matrix.woltje.com
|
||||
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
|
||||
# MATRIX_WORKSPACE_ID=your-workspace-uuid
|
||||
|
||||
# ======================
|
||||
# Matrix / Synapse Deployment
|
||||
# ======================
|
||||
# Domains for Traefik routing to Matrix services
|
||||
MATRIX_DOMAIN=matrix.example.com
|
||||
ELEMENT_DOMAIN=chat.example.com
|
||||
MATRIX_DOMAIN=matrix.woltje.com
|
||||
ELEMENT_DOMAIN=chat.woltje.com
|
||||
|
||||
# Synapse database (created automatically by synapse-db-init in the swarm compose)
|
||||
SYNAPSE_POSTGRES_DB=synapse
|
||||
|
||||
90
.mosaic/orchestrator/mission.json
Normal file
90
.mosaic/orchestrator/mission.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"mission_id": "ms21-multi-tenant-rbac-data-migration-20260228",
|
||||
"name": "MS21 Multi-Tenant RBAC Data Migration",
|
||||
"description": "Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack",
|
||||
"project_path": "/home/jwoltje/src/mosaic-stack",
|
||||
"created_at": "2026-02-28T17:10:22Z",
|
||||
"status": "active",
|
||||
"task_prefix": "MS21",
|
||||
"quality_gates": "pnpm lint && pnpm build && pnpm test",
|
||||
"milestone_version": "0.0.21",
|
||||
"milestones": [
|
||||
{
|
||||
"id": "phase-1",
|
||||
"name": "Schema and Admin API",
|
||||
"status": "pending",
|
||||
"branch": "schema-and-admin-api",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-2",
|
||||
"name": "Break-Glass Authentication",
|
||||
"status": "pending",
|
||||
"branch": "break-glass-authentication",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-3",
|
||||
"name": "Data Migration",
|
||||
"status": "pending",
|
||||
"branch": "data-migration",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-4",
|
||||
"name": "Admin UI",
|
||||
"status": "pending",
|
||||
"branch": "admin-ui",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-5",
|
||||
"name": "RBAC UI Enforcement",
|
||||
"status": "pending",
|
||||
"branch": "rbac-ui-enforcement",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
},
|
||||
{
|
||||
"id": "phase-6",
|
||||
"name": "Verification",
|
||||
"status": "pending",
|
||||
"branch": "verification",
|
||||
"issue_ref": "",
|
||||
"started_at": "",
|
||||
"completed_at": ""
|
||||
}
|
||||
],
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": "sess-001",
|
||||
"runtime": "unknown",
|
||||
"started_at": "2026-02-28T17:48:51Z",
|
||||
"ended_at": "",
|
||||
"ended_reason": "",
|
||||
"milestone_at_end": "",
|
||||
"tasks_completed": [],
|
||||
"last_task_id": ""
|
||||
},
|
||||
{
|
||||
"session_id": "sess-002",
|
||||
"runtime": "unknown",
|
||||
"started_at": "2026-02-28T20:30:13Z",
|
||||
"ended_at": "",
|
||||
"ended_reason": "",
|
||||
"milestone_at_end": "",
|
||||
"tasks_completed": [],
|
||||
"last_task_id": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
8
.mosaic/orchestrator/session.lock
Normal file
8
.mosaic/orchestrator/session.lock
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"session_id": "sess-002",
|
||||
"runtime": "unknown",
|
||||
"pid": 3178395,
|
||||
"started_at": "2026-02-28T20:30:13Z",
|
||||
"project_path": "/tmp/ms21-ui-001",
|
||||
"milestone_id": ""
|
||||
}
|
||||
@@ -24,6 +24,13 @@ variables:
|
||||
pnpm install --frozen-lockfile
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
- &turbo_env
|
||||
TURBO_API:
|
||||
from_secret: turbo_api
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
TURBO_TEAM:
|
||||
from_secret: turbo_team
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
@@ -52,17 +59,6 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/api" lint
|
||||
depends_on:
|
||||
- prisma-generate
|
||||
- build-shared
|
||||
|
||||
prisma-generate:
|
||||
image: *node_image
|
||||
environment:
|
||||
@@ -73,26 +69,27 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
build-shared:
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/shared" build
|
||||
- pnpm turbo lint --filter=@mosaic/api
|
||||
depends_on:
|
||||
- install
|
||||
- prisma-generate
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/api" typecheck
|
||||
- pnpm turbo typecheck --filter=@mosaic/api
|
||||
depends_on:
|
||||
- prisma-generate
|
||||
- build-shared
|
||||
|
||||
prisma-migrate:
|
||||
image: *node_image
|
||||
@@ -124,6 +121,7 @@ steps:
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
NODE_ENV: "production"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm turbo build --filter=@mosaic/api
|
||||
|
||||
@@ -24,6 +24,13 @@ variables:
|
||||
pnpm install --frozen-lockfile
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
- &turbo_env
|
||||
TURBO_API:
|
||||
from_secret: turbo_api
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
TURBO_TEAM:
|
||||
from_secret: turbo_team
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
@@ -48,9 +55,10 @@ steps:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/orchestrator" lint
|
||||
- pnpm turbo lint --filter=@mosaic/orchestrator
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
@@ -58,9 +66,10 @@ steps:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/orchestrator" typecheck
|
||||
- pnpm turbo typecheck --filter=@mosaic/orchestrator
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
@@ -68,9 +77,10 @@ steps:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/orchestrator" test
|
||||
- pnpm turbo test --filter=@mosaic/orchestrator
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
@@ -81,6 +91,7 @@ steps:
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
NODE_ENV: "production"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm turbo build --filter=@mosaic/orchestrator
|
||||
|
||||
@@ -24,6 +24,13 @@ variables:
|
||||
pnpm install --frozen-lockfile
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
- &turbo_env
|
||||
TURBO_API:
|
||||
from_secret: turbo_api
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
TURBO_TEAM:
|
||||
from_secret: turbo_team
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
@@ -44,46 +51,38 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
build-shared:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/shared" build
|
||||
- pnpm --filter "@mosaic/ui" build
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/web" lint
|
||||
- pnpm turbo lint --filter=@mosaic/web
|
||||
depends_on:
|
||||
- build-shared
|
||||
- install
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/web" typecheck
|
||||
- pnpm turbo typecheck --filter=@mosaic/web
|
||||
depends_on:
|
||||
- build-shared
|
||||
- install
|
||||
|
||||
test:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/web" test
|
||||
- pnpm turbo test --filter=@mosaic/web
|
||||
depends_on:
|
||||
- build-shared
|
||||
- install
|
||||
|
||||
# === Build ===
|
||||
|
||||
@@ -92,6 +91,7 @@ steps:
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
NODE_ENV: "production"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm turbo build --filter=@mosaic/web
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -46,6 +46,21 @@ pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Versioning Protocol (HARD GATE)
|
||||
|
||||
**This project is ALPHA. All versions MUST be `0.0.x`.**
|
||||
|
||||
- The `0.1.0` release is FORBIDDEN until Jason explicitly authorizes it.
|
||||
- Every milestone bump increments the patch: `0.0.20` → `0.0.21` → `0.0.22`, etc.
|
||||
- ALL package.json files in the monorepo MUST stay in sync at the same version.
|
||||
- Use `scripts/version-bump.sh <version>` to bump — it enforces the alpha constraint and updates all packages atomically.
|
||||
- The script rejects any version >= `0.1.0`.
|
||||
- When creating a release tag, the tag MUST match the package version: `v0.0.x`.
|
||||
|
||||
**Milestone-to-version mapping** is defined in the PRD (`docs/PRD.md`) under "Delivery/Milestone Intent". Agents MUST use the version from that table when tagging a milestone release.
|
||||
|
||||
**Violation of this protocol is a blocking error.** If an agent attempts to set a version >= `0.1.0`, stop and escalate.
|
||||
|
||||
## Standards and Quality
|
||||
|
||||
- Enforce strict typing and no unsafe shortcuts.
|
||||
|
||||
@@ -18,6 +18,12 @@ COPY turbo.json ./
|
||||
# ======================
|
||||
FROM base AS deps
|
||||
|
||||
# Install build tools for native addons (node-pty requires node-gyp compilation)
|
||||
# and OpenSSL for Prisma engine detection
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy all package.json files for workspace resolution
|
||||
COPY packages/shared/package.json ./packages/shared/
|
||||
COPY packages/ui/package.json ./packages/ui/
|
||||
@@ -25,7 +31,11 @@ COPY packages/config/package.json ./packages/config/
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
|
||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
||||
# scripts or fail to find prebuilt binaries for this Node.js version
|
||||
RUN pnpm install --frozen-lockfile \
|
||||
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
|
||||
&& npx node-gyp rebuild 2>&1 || true
|
||||
|
||||
# ======================
|
||||
# Builder stage
|
||||
@@ -58,7 +68,11 @@ FROM node:24-slim AS production
|
||||
ADD https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 /usr/local/bin/dumb-init
|
||||
|
||||
# Single RUN to minimize Kaniko filesystem snapshots (each RUN = full snapshot)
|
||||
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||
# - openssl: Prisma engine detection requires libssl
|
||||
# - No build tools needed here — native addons are compiled in the deps stage
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx \
|
||||
&& chmod 755 /usr/local/bin/dumb-init \
|
||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/api",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
@@ -52,6 +52,7 @@
|
||||
"adm-zip": "^0.5.16",
|
||||
"archiver": "^7.0.1",
|
||||
"axios": "^1.13.5",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "^1.4.17",
|
||||
"bullmq": "^5.67.2",
|
||||
"class-transformer": "^0.5.1",
|
||||
@@ -66,6 +67,7 @@
|
||||
"marked-gfm-heading-id": "^4.1.3",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"matrix-bot-sdk": "^0.8.0",
|
||||
"node-pty": "^1.0.0",
|
||||
"ollama": "^0.6.3",
|
||||
"openai": "^6.17.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
@@ -84,6 +86,7 @@
|
||||
"@swc/core": "^1.10.18",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/express": "^5.0.1",
|
||||
"@types/highlight.js": "^10.1.0",
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "TerminalSessionStatus" AS ENUM ('ACTIVE', 'CLOSED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "terminal_sessions" (
|
||||
"id" UUID NOT NULL,
|
||||
"workspace_id" UUID NOT NULL,
|
||||
"name" TEXT NOT NULL DEFAULT 'Terminal',
|
||||
"status" "TerminalSessionStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"closed_at" TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT "terminal_sessions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "terminal_sessions_workspace_id_idx" ON "terminal_sessions"("workspace_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "terminal_sessions_workspace_id_status_idx" ON "terminal_sessions"("workspace_id", "status");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "terminal_sessions" ADD CONSTRAINT "terminal_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable: add tone and formality_level columns to personalities
|
||||
ALTER TABLE "personalities" ADD COLUMN "tone" TEXT NOT NULL DEFAULT 'neutral';
|
||||
ALTER TABLE "personalities" ADD COLUMN "formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL';
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
previewFeatures = ["postgresqlExtensions"]
|
||||
}
|
||||
|
||||
@@ -206,6 +207,11 @@ enum CredentialScope {
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
enum TerminalSessionStatus {
|
||||
ACTIVE
|
||||
CLOSED
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// MODELS
|
||||
// ============================================
|
||||
@@ -221,6 +227,14 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||
|
||||
// MS21: Admin, local auth, and invitation fields
|
||||
deactivatedAt DateTime? @map("deactivated_at") @db.Timestamptz
|
||||
isLocalAuth Boolean @default(false) @map("is_local_auth")
|
||||
passwordHash String? @map("password_hash")
|
||||
invitedBy String? @map("invited_by") @db.Uuid
|
||||
invitationToken String? @unique @map("invitation_token")
|
||||
invitedAt DateTime? @map("invited_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
|
||||
workspaceMemberships WorkspaceMember[]
|
||||
@@ -297,6 +311,7 @@ model Workspace {
|
||||
federationEventSubscriptions FederationEventSubscription[]
|
||||
llmUsageLogs LlmUsageLog[]
|
||||
userCredentials UserCredential[]
|
||||
terminalSessions TerminalSession[]
|
||||
|
||||
@@index([ownerId])
|
||||
@@map("workspaces")
|
||||
@@ -1061,6 +1076,10 @@ model Personality {
|
||||
displayName String @map("display_name")
|
||||
description String? @db.Text
|
||||
|
||||
// Tone and formality
|
||||
tone String @default("neutral")
|
||||
formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
|
||||
|
||||
// System prompt
|
||||
systemPrompt String @map("system_prompt") @db.Text
|
||||
|
||||
@@ -1507,3 +1526,23 @@ model LlmUsageLog {
|
||||
@@index([conversationId])
|
||||
@@map("llm_usage_logs")
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TERMINAL MODULE
|
||||
// ============================================
|
||||
|
||||
model TerminalSession {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
workspaceId String @map("workspace_id") @db.Uuid
|
||||
name String @default("Terminal")
|
||||
status TerminalSessionStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||
closedAt DateTime? @map("closed_at") @db.Timestamptz
|
||||
|
||||
// Relations
|
||||
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([workspaceId])
|
||||
@@index([workspaceId, status])
|
||||
@@map("terminal_sessions")
|
||||
}
|
||||
|
||||
@@ -65,6 +65,136 @@ async function main() {
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// WIDGET DEFINITIONS (global, not workspace-scoped)
|
||||
// ============================================
|
||||
const widgetDefs = [
|
||||
{
|
||||
name: "TasksWidget",
|
||||
displayName: "Tasks",
|
||||
description: "View and manage your tasks",
|
||||
component: "TasksWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "CalendarWidget",
|
||||
displayName: "Calendar",
|
||||
description: "View upcoming events and schedule",
|
||||
component: "CalendarWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "QuickCaptureWidget",
|
||||
displayName: "Quick Capture",
|
||||
description: "Quickly capture notes and tasks",
|
||||
component: "QuickCaptureWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 1,
|
||||
minWidth: 2,
|
||||
minHeight: 1,
|
||||
maxWidth: 4,
|
||||
maxHeight: 2,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "AgentStatusWidget",
|
||||
displayName: "Agent Status",
|
||||
description: "Monitor agent activity and status",
|
||||
component: "AgentStatusWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 3,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "ActiveProjectsWidget",
|
||||
displayName: "Active Projects & Agent Chains",
|
||||
description: "View active projects and running agent sessions",
|
||||
component: "ActiveProjectsWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 3,
|
||||
minWidth: 2,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "TaskProgressWidget",
|
||||
displayName: "Task Progress",
|
||||
description: "Live progress of orchestrator agent tasks",
|
||||
component: "TaskProgressWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 3,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
{
|
||||
name: "OrchestratorEventsWidget",
|
||||
displayName: "Orchestrator Events",
|
||||
description: "Recent orchestration events with stream/Matrix visibility",
|
||||
component: "OrchestratorEventsWidget",
|
||||
defaultWidth: 2,
|
||||
defaultHeight: 2,
|
||||
minWidth: 1,
|
||||
minHeight: 2,
|
||||
maxWidth: 4,
|
||||
maxHeight: null,
|
||||
configSchema: {},
|
||||
},
|
||||
];
|
||||
|
||||
for (const wd of widgetDefs) {
|
||||
await prisma.widgetDefinition.upsert({
|
||||
where: { name: wd.name },
|
||||
update: {
|
||||
displayName: wd.displayName,
|
||||
description: wd.description,
|
||||
component: wd.component,
|
||||
defaultWidth: wd.defaultWidth,
|
||||
defaultHeight: wd.defaultHeight,
|
||||
minWidth: wd.minWidth,
|
||||
minHeight: wd.minHeight,
|
||||
maxWidth: wd.maxWidth,
|
||||
maxHeight: wd.maxHeight,
|
||||
configSchema: wd.configSchema,
|
||||
},
|
||||
create: {
|
||||
name: wd.name,
|
||||
displayName: wd.displayName,
|
||||
description: wd.description,
|
||||
component: wd.component,
|
||||
defaultWidth: wd.defaultWidth,
|
||||
defaultHeight: wd.defaultHeight,
|
||||
minWidth: wd.minWidth,
|
||||
minHeight: wd.minHeight,
|
||||
maxWidth: wd.maxWidth,
|
||||
maxHeight: wd.maxHeight,
|
||||
configSchema: wd.configSchema,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Seeded ${widgetDefs.length} widget definitions`);
|
||||
|
||||
// Use transaction for atomic seed data reset and creation
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Delete existing seed data for idempotency (avoids duplicates on re-run)
|
||||
|
||||
258
apps/api/src/admin/admin.controller.spec.ts
Normal file
258
apps/api/src/admin/admin.controller.spec.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import type { ExecutionContext } from "@nestjs/common";
|
||||
|
||||
describe("AdminController", () => {
|
||||
let controller: AdminController;
|
||||
let service: AdminService;
|
||||
|
||||
const mockAdminService = {
|
||||
listUsers: vi.fn(),
|
||||
inviteUser: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
deactivateUser: vi.fn(),
|
||||
createWorkspace: vi.fn(),
|
||||
updateWorkspace: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAuthGuard = {
|
||||
canActivate: vi.fn((context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
request.user = {
|
||||
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||
email: "admin@example.com",
|
||||
name: "Admin User",
|
||||
};
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
|
||||
const mockAdminGuard = {
|
||||
canActivate: vi.fn(() => true),
|
||||
};
|
||||
|
||||
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
const mockAdminUser = {
|
||||
id: mockAdminId,
|
||||
email: "admin@example.com",
|
||||
name: "Admin User",
|
||||
};
|
||||
|
||||
const mockUserResponse = {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: false,
|
||||
image: null,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
invitedAt: null,
|
||||
invitedBy: null,
|
||||
workspaceMemberships: [],
|
||||
};
|
||||
|
||||
const mockWorkspaceResponse = {
|
||||
id: mockWorkspaceId,
|
||||
name: "Test Workspace",
|
||||
ownerId: mockAdminId,
|
||||
settings: {},
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
memberCount: 1,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
{
|
||||
provide: AdminService,
|
||||
useValue: mockAdminService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(mockAuthGuard)
|
||||
.overrideGuard(AdminGuard)
|
||||
.useValue(mockAdminGuard)
|
||||
.compile();
|
||||
|
||||
controller = module.get<AdminController>(AdminController);
|
||||
service = module.get<AdminService>(AdminService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("listUsers", () => {
|
||||
it("should return paginated users", async () => {
|
||||
const paginatedResult = {
|
||||
data: [mockUserResponse],
|
||||
meta: { total: 1, page: 1, limit: 50, totalPages: 1 },
|
||||
};
|
||||
mockAdminService.listUsers.mockResolvedValue(paginatedResult);
|
||||
|
||||
const result = await controller.listUsers({ page: 1, limit: 50 });
|
||||
|
||||
expect(result).toEqual(paginatedResult);
|
||||
expect(service.listUsers).toHaveBeenCalledWith(1, 50);
|
||||
});
|
||||
|
||||
it("should use default pagination", async () => {
|
||||
const paginatedResult = {
|
||||
data: [],
|
||||
meta: { total: 0, page: 1, limit: 50, totalPages: 0 },
|
||||
};
|
||||
mockAdminService.listUsers.mockResolvedValue(paginatedResult);
|
||||
|
||||
await controller.listUsers({});
|
||||
|
||||
expect(service.listUsers).toHaveBeenCalledWith(undefined, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inviteUser", () => {
|
||||
it("should invite a user", async () => {
|
||||
const inviteDto = { email: "new@example.com" };
|
||||
const invitationResponse = {
|
||||
userId: "new-id",
|
||||
invitationToken: "token",
|
||||
email: "new@example.com",
|
||||
invitedAt: new Date(),
|
||||
};
|
||||
mockAdminService.inviteUser.mockResolvedValue(invitationResponse);
|
||||
|
||||
const result = await controller.inviteUser(inviteDto, mockAdminUser);
|
||||
|
||||
expect(result).toEqual(invitationResponse);
|
||||
expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId);
|
||||
});
|
||||
|
||||
it("should invite a user with workspace and role", async () => {
|
||||
const inviteDto = {
|
||||
email: "new@example.com",
|
||||
workspaceId: mockWorkspaceId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
};
|
||||
mockAdminService.inviteUser.mockResolvedValue({
|
||||
userId: "new-id",
|
||||
invitationToken: "token",
|
||||
email: "new@example.com",
|
||||
invitedAt: new Date(),
|
||||
});
|
||||
|
||||
await controller.inviteUser(inviteDto, mockAdminUser);
|
||||
|
||||
expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUser", () => {
|
||||
it("should update a user", async () => {
|
||||
const updateDto = { name: "Updated Name" };
|
||||
mockAdminService.updateUser.mockResolvedValue({
|
||||
...mockUserResponse,
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
const result = await controller.updateUser(mockUserId, updateDto);
|
||||
|
||||
expect(result.name).toBe("Updated Name");
|
||||
expect(service.updateUser).toHaveBeenCalledWith(mockUserId, updateDto);
|
||||
});
|
||||
|
||||
it("should deactivate a user via update", async () => {
|
||||
const deactivatedAt = "2026-02-28T00:00:00.000Z";
|
||||
const updateDto = { deactivatedAt };
|
||||
mockAdminService.updateUser.mockResolvedValue({
|
||||
...mockUserResponse,
|
||||
deactivatedAt: new Date(deactivatedAt),
|
||||
});
|
||||
|
||||
const result = await controller.updateUser(mockUserId, updateDto);
|
||||
|
||||
expect(result.deactivatedAt).toEqual(new Date(deactivatedAt));
|
||||
});
|
||||
});
|
||||
|
||||
describe("deactivateUser", () => {
|
||||
it("should soft-delete a user", async () => {
|
||||
mockAdminService.deactivateUser.mockResolvedValue({
|
||||
...mockUserResponse,
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await controller.deactivateUser(mockUserId);
|
||||
|
||||
expect(result.deactivatedAt).toBeDefined();
|
||||
expect(service.deactivateUser).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorkspace", () => {
|
||||
it("should create a workspace", async () => {
|
||||
const createDto = { name: "New Workspace", ownerId: mockAdminId };
|
||||
mockAdminService.createWorkspace.mockResolvedValue(mockWorkspaceResponse);
|
||||
|
||||
const result = await controller.createWorkspace(createDto);
|
||||
|
||||
expect(result).toEqual(mockWorkspaceResponse);
|
||||
expect(service.createWorkspace).toHaveBeenCalledWith(createDto);
|
||||
});
|
||||
|
||||
it("should create workspace with settings", async () => {
|
||||
const createDto = {
|
||||
name: "New Workspace",
|
||||
ownerId: mockAdminId,
|
||||
settings: { feature: true },
|
||||
};
|
||||
mockAdminService.createWorkspace.mockResolvedValue({
|
||||
...mockWorkspaceResponse,
|
||||
settings: { feature: true },
|
||||
});
|
||||
|
||||
const result = await controller.createWorkspace(createDto);
|
||||
|
||||
expect(result.settings).toEqual({ feature: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWorkspace", () => {
|
||||
it("should update a workspace", async () => {
|
||||
const updateDto = { name: "Updated Workspace" };
|
||||
mockAdminService.updateWorkspace.mockResolvedValue({
|
||||
...mockWorkspaceResponse,
|
||||
name: "Updated Workspace",
|
||||
});
|
||||
|
||||
const result = await controller.updateWorkspace(mockWorkspaceId, updateDto);
|
||||
|
||||
expect(result.name).toBe("Updated Workspace");
|
||||
expect(service.updateWorkspace).toHaveBeenCalledWith(mockWorkspaceId, updateDto);
|
||||
});
|
||||
|
||||
it("should update workspace settings", async () => {
|
||||
const updateDto = { settings: { notifications: false } };
|
||||
mockAdminService.updateWorkspace.mockResolvedValue({
|
||||
...mockWorkspaceResponse,
|
||||
settings: { notifications: false },
|
||||
});
|
||||
|
||||
const result = await controller.updateWorkspace(mockWorkspaceId, updateDto);
|
||||
|
||||
expect(result.settings).toEqual({ notifications: false });
|
||||
});
|
||||
});
|
||||
});
|
||||
64
apps/api/src/admin/admin.controller.ts
Normal file
64
apps/api/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Patch,
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
ParseUUIDPipe,
|
||||
} from "@nestjs/common";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||
import type { AuthUser } from "@mosaic/shared";
|
||||
import { InviteUserDto } from "./dto/invite-user.dto";
|
||||
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||
import { CreateWorkspaceDto } from "./dto/create-workspace.dto";
|
||||
import { UpdateWorkspaceDto } from "./dto/update-workspace.dto";
|
||||
import { QueryUsersDto } from "./dto/query-users.dto";
|
||||
|
||||
@Controller("admin")
|
||||
@UseGuards(AuthGuard, AdminGuard)
|
||||
export class AdminController {
|
||||
constructor(private readonly adminService: AdminService) {}
|
||||
|
||||
@Get("users")
|
||||
async listUsers(@Query() query: QueryUsersDto) {
|
||||
return this.adminService.listUsers(query.page, query.limit);
|
||||
}
|
||||
|
||||
@Post("users/invite")
|
||||
async inviteUser(@Body() dto: InviteUserDto, @CurrentUser() user: AuthUser) {
|
||||
return this.adminService.inviteUser(dto, user.id);
|
||||
}
|
||||
|
||||
@Patch("users/:id")
|
||||
async updateUser(
|
||||
@Param("id", new ParseUUIDPipe({ version: "4" })) id: string,
|
||||
@Body() dto: UpdateUserDto
|
||||
) {
|
||||
return this.adminService.updateUser(id, dto);
|
||||
}
|
||||
|
||||
@Delete("users/:id")
|
||||
async deactivateUser(@Param("id", new ParseUUIDPipe({ version: "4" })) id: string) {
|
||||
return this.adminService.deactivateUser(id);
|
||||
}
|
||||
|
||||
@Post("workspaces")
|
||||
async createWorkspace(@Body() dto: CreateWorkspaceDto) {
|
||||
return this.adminService.createWorkspace(dto);
|
||||
}
|
||||
|
||||
@Patch("workspaces/:id")
|
||||
async updateWorkspace(
|
||||
@Param("id", new ParseUUIDPipe({ version: "4" })) id: string,
|
||||
@Body() dto: UpdateWorkspaceDto
|
||||
) {
|
||||
return this.adminService.updateWorkspace(id, dto);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/admin/admin.module.ts
Normal file
13
apps/api/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AdminController } from "./admin.controller";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
exports: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
471
apps/api/src/admin/admin.service.spec.ts
Normal file
471
apps/api/src/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { AdminService } from "./admin.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
|
||||
describe("AdminService", () => {
|
||||
let service: AdminService;
|
||||
|
||||
const mockPrismaService = {
|
||||
user: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
workspace: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
workspaceMember: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
const mockUser = {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: false,
|
||||
image: null,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
deactivatedAt: null,
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
invitedBy: null,
|
||||
invitationToken: null,
|
||||
invitedAt: null,
|
||||
authProviderId: null,
|
||||
preferences: {},
|
||||
workspaceMemberships: [
|
||||
{
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
role: WorkspaceMemberRole.MEMBER,
|
||||
joinedAt: new Date("2026-01-01"),
|
||||
workspace: { id: mockWorkspaceId, name: "Test Workspace" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockWorkspace = {
|
||||
id: mockWorkspaceId,
|
||||
name: "Test Workspace",
|
||||
ownerId: mockAdminId,
|
||||
settings: {},
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
matrixRoomId: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AdminService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AdminService>(AdminService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockPrismaService.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => {
|
||||
return fn(mockPrismaService);
|
||||
});
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("listUsers", () => {
|
||||
it("should return paginated users with memberships", async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([mockUser]);
|
||||
mockPrismaService.user.count.mockResolvedValue(1);
|
||||
|
||||
const result = await service.listUsers(1, 50);
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0]?.id).toBe(mockUserId);
|
||||
expect(result.data[0]?.workspaceMemberships).toHaveLength(1);
|
||||
expect(result.meta).toEqual({
|
||||
total: 1,
|
||||
page: 1,
|
||||
limit: 50,
|
||||
totalPages: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("should use default pagination when not provided", async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.user.count.mockResolvedValue(0);
|
||||
|
||||
await service.listUsers();
|
||||
|
||||
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 0,
|
||||
take: 50,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should calculate pagination correctly", async () => {
|
||||
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||
mockPrismaService.user.count.mockResolvedValue(150);
|
||||
|
||||
const result = await service.listUsers(3, 25);
|
||||
|
||||
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
skip: 50,
|
||||
take: 25,
|
||||
})
|
||||
);
|
||||
expect(result.meta.totalPages).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inviteUser", () => {
|
||||
it("should create a user with invitation token", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
const createdUser = {
|
||||
id: "new-user-id",
|
||||
email: "new@example.com",
|
||||
name: "new",
|
||||
invitationToken: "some-token",
|
||||
};
|
||||
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||
|
||||
const result = await service.inviteUser({ email: "new@example.com" }, mockAdminId);
|
||||
|
||||
expect(result.email).toBe("new@example.com");
|
||||
expect(result.invitationToken).toBeDefined();
|
||||
expect(result.userId).toBe("new-user-id");
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
email: "new@example.com",
|
||||
invitedBy: mockAdminId,
|
||||
invitationToken: expect.any(String),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should add user to workspace when workspaceId provided", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||
const createdUser = { id: "new-user-id", email: "new@example.com", name: "new" };
|
||||
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||
|
||||
await service.inviteUser(
|
||||
{
|
||||
email: "new@example.com",
|
||||
workspaceId: mockWorkspaceId,
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
},
|
||||
mockAdminId
|
||||
);
|
||||
|
||||
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: "new-user-id",
|
||||
role: WorkspaceMemberRole.ADMIN,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw ConflictException if email already exists", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
|
||||
await expect(service.inviteUser({ email: "test@example.com" }, mockAdminId)).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if workspace does not exist", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.inviteUser({ email: "new@example.com", workspaceId: "non-existent" }, mockAdminId)
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should use email prefix as default name", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
const createdUser = { id: "new-user-id", email: "jane.doe@example.com", name: "jane.doe" };
|
||||
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||
|
||||
await service.inviteUser({ email: "jane.doe@example.com" }, mockAdminId);
|
||||
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: "jane.doe",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should use provided name when given", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
const createdUser = { id: "new-user-id", email: "j@example.com", name: "Jane Doe" };
|
||||
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||
|
||||
await service.inviteUser({ email: "j@example.com", name: "Jane Doe" }, mockAdminId);
|
||||
|
||||
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
name: "Jane Doe",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateUser", () => {
|
||||
it("should update user fields", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
name: "Updated Name",
|
||||
});
|
||||
|
||||
const result = await service.updateUser(mockUserId, { name: "Updated Name" });
|
||||
|
||||
expect(result.name).toBe("Updated Name");
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: mockUserId },
|
||||
data: { name: "Updated Name" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should set deactivatedAt when provided", async () => {
|
||||
const deactivatedAt = "2026-02-28T00:00:00.000Z";
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
deactivatedAt: new Date(deactivatedAt),
|
||||
});
|
||||
|
||||
const result = await service.updateUser(mockUserId, { deactivatedAt });
|
||||
|
||||
expect(result.deactivatedAt).toEqual(new Date(deactivatedAt));
|
||||
});
|
||||
|
||||
it("should clear deactivatedAt when set to null", async () => {
|
||||
const deactivatedUser = { ...mockUser, deactivatedAt: new Date() };
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(deactivatedUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...deactivatedUser,
|
||||
deactivatedAt: null,
|
||||
});
|
||||
|
||||
const result = await service.updateUser(mockUserId, { deactivatedAt: null });
|
||||
|
||||
expect(result.deactivatedAt).toBeNull();
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if user does not exist", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateUser("non-existent", { name: "Test" })).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should update emailVerified", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
emailVerified: true,
|
||||
});
|
||||
|
||||
const result = await service.updateUser(mockUserId, { emailVerified: true });
|
||||
|
||||
expect(result.emailVerified).toBe(true);
|
||||
});
|
||||
|
||||
it("should update preferences", async () => {
|
||||
const prefs = { theme: "dark" };
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
preferences: prefs,
|
||||
});
|
||||
|
||||
await service.updateUser(mockUserId, { preferences: prefs });
|
||||
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ preferences: prefs }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deactivateUser", () => {
|
||||
it("should set deactivatedAt on the user", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.user.update.mockResolvedValue({
|
||||
...mockUser,
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.deactivateUser(mockUserId);
|
||||
|
||||
expect(result.deactivatedAt).toBeDefined();
|
||||
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: mockUserId },
|
||||
data: { deactivatedAt: expect.any(Date) },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if user does not exist", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deactivateUser("non-existent")).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw BadRequestException if user is already deactivated", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
...mockUser,
|
||||
deactivatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(service.deactivateUser(mockUserId)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createWorkspace", () => {
|
||||
it("should create a workspace with owner membership", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.workspace.create.mockResolvedValue(mockWorkspace);
|
||||
|
||||
const result = await service.createWorkspace({
|
||||
name: "New Workspace",
|
||||
ownerId: mockAdminId,
|
||||
});
|
||||
|
||||
expect(result.name).toBe("Test Workspace");
|
||||
expect(result.memberCount).toBe(1);
|
||||
expect(mockPrismaService.workspace.create).toHaveBeenCalled();
|
||||
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspace.id,
|
||||
userId: mockAdminId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if owner does not exist", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.createWorkspace({ name: "New Workspace", ownerId: "non-existent" })
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should pass settings when provided", async () => {
|
||||
const settings = { theme: "dark", features: ["chat"] };
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||
mockPrismaService.workspace.create.mockResolvedValue({
|
||||
...mockWorkspace,
|
||||
settings,
|
||||
});
|
||||
|
||||
await service.createWorkspace({
|
||||
name: "New Workspace",
|
||||
ownerId: mockAdminId,
|
||||
settings,
|
||||
});
|
||||
|
||||
expect(mockPrismaService.workspace.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ settings }),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("updateWorkspace", () => {
|
||||
it("should update workspace name", async () => {
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||
mockPrismaService.workspace.update.mockResolvedValue({
|
||||
...mockWorkspace,
|
||||
name: "Updated Workspace",
|
||||
_count: { members: 3 },
|
||||
});
|
||||
|
||||
const result = await service.updateWorkspace(mockWorkspaceId, {
|
||||
name: "Updated Workspace",
|
||||
});
|
||||
|
||||
expect(result.name).toBe("Updated Workspace");
|
||||
expect(result.memberCount).toBe(3);
|
||||
});
|
||||
|
||||
it("should update workspace settings", async () => {
|
||||
const newSettings = { notifications: true };
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||
mockPrismaService.workspace.update.mockResolvedValue({
|
||||
...mockWorkspace,
|
||||
settings: newSettings,
|
||||
_count: { members: 1 },
|
||||
});
|
||||
|
||||
const result = await service.updateWorkspace(mockWorkspaceId, {
|
||||
settings: newSettings,
|
||||
});
|
||||
|
||||
expect(result.settings).toEqual(newSettings);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException if workspace does not exist", async () => {
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateWorkspace("non-existent", { name: "Test" })).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should only update provided fields", async () => {
|
||||
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||
mockPrismaService.workspace.update.mockResolvedValue({
|
||||
...mockWorkspace,
|
||||
_count: { members: 1 },
|
||||
});
|
||||
|
||||
await service.updateWorkspace(mockWorkspaceId, { name: "Only Name" });
|
||||
|
||||
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { name: "Only Name" },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
306
apps/api/src/admin/admin.service.ts
Normal file
306
apps/api/src/admin/admin.service.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { Prisma, WorkspaceMemberRole } from "@prisma/client";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type { InviteUserDto } from "./dto/invite-user.dto";
|
||||
import type { UpdateUserDto } from "./dto/update-user.dto";
|
||||
import type { CreateWorkspaceDto } from "./dto/create-workspace.dto";
|
||||
import type {
|
||||
AdminUserResponse,
|
||||
AdminWorkspaceResponse,
|
||||
InvitationResponse,
|
||||
PaginatedResponse,
|
||||
} from "./types/admin.types";
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
private readonly logger = new Logger(AdminService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listUsers(page = 1, limit = 50): Promise<PaginatedResponse<AdminUserResponse>> {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
this.prisma.user.findMany({
|
||||
include: {
|
||||
workspaceMemberships: {
|
||||
include: {
|
||||
workspace: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.user.count(),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: users.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
image: user.image,
|
||||
createdAt: user.createdAt,
|
||||
deactivatedAt: user.deactivatedAt,
|
||||
isLocalAuth: user.isLocalAuth,
|
||||
invitedAt: user.invitedAt,
|
||||
invitedBy: user.invitedBy,
|
||||
workspaceMemberships: user.workspaceMemberships.map((m) => ({
|
||||
workspaceId: m.workspaceId,
|
||||
workspaceName: m.workspace.name,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
})),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async inviteUser(dto: InviteUserDto, inviterId: string): Promise<InvitationResponse> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email: dto.email },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException(`User with email ${dto.email} already exists`);
|
||||
}
|
||||
|
||||
if (dto.workspaceId) {
|
||||
const workspace = await this.prisma.workspace.findUnique({
|
||||
where: { id: dto.workspaceId },
|
||||
});
|
||||
if (!workspace) {
|
||||
throw new NotFoundException(`Workspace ${dto.workspaceId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
const invitationToken = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
const user = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.user.create({
|
||||
data: {
|
||||
email: dto.email,
|
||||
name: dto.name ?? dto.email.split("@")[0] ?? dto.email,
|
||||
emailVerified: false,
|
||||
invitedBy: inviterId,
|
||||
invitationToken,
|
||||
invitedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.workspaceId) {
|
||||
await tx.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId: dto.workspaceId,
|
||||
userId: created.id,
|
||||
role: dto.role ?? WorkspaceMemberRole.MEMBER,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
this.logger.log(`User invited: ${user.email} by ${inviterId}`);
|
||||
|
||||
return {
|
||||
userId: user.id,
|
||||
invitationToken,
|
||||
email: user.email,
|
||||
invitedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
async updateUser(id: string, dto: UpdateUserDto): Promise<AdminUserResponse> {
|
||||
const existing = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`User ${id} not found`);
|
||||
}
|
||||
|
||||
const data: Prisma.UserUpdateInput = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
data.name = dto.name;
|
||||
}
|
||||
if (dto.emailVerified !== undefined) {
|
||||
data.emailVerified = dto.emailVerified;
|
||||
}
|
||||
if (dto.preferences !== undefined) {
|
||||
data.preferences = dto.preferences as Prisma.InputJsonValue;
|
||||
}
|
||||
if (dto.deactivatedAt !== undefined) {
|
||||
data.deactivatedAt = dto.deactivatedAt ? new Date(dto.deactivatedAt) : null;
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
workspaceMemberships: {
|
||||
include: {
|
||||
workspace: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`User updated: ${id}`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
image: user.image,
|
||||
createdAt: user.createdAt,
|
||||
deactivatedAt: user.deactivatedAt,
|
||||
isLocalAuth: user.isLocalAuth,
|
||||
invitedAt: user.invitedAt,
|
||||
invitedBy: user.invitedBy,
|
||||
workspaceMemberships: user.workspaceMemberships.map((m) => ({
|
||||
workspaceId: m.workspaceId,
|
||||
workspaceName: m.workspace.name,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async deactivateUser(id: string): Promise<AdminUserResponse> {
|
||||
const existing = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`User ${id} not found`);
|
||||
}
|
||||
|
||||
if (existing.deactivatedAt) {
|
||||
throw new BadRequestException(`User ${id} is already deactivated`);
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({
|
||||
where: { id },
|
||||
data: { deactivatedAt: new Date() },
|
||||
include: {
|
||||
workspaceMemberships: {
|
||||
include: {
|
||||
workspace: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`User deactivated: ${id}`);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: user.emailVerified,
|
||||
image: user.image,
|
||||
createdAt: user.createdAt,
|
||||
deactivatedAt: user.deactivatedAt,
|
||||
isLocalAuth: user.isLocalAuth,
|
||||
invitedAt: user.invitedAt,
|
||||
invitedBy: user.invitedBy,
|
||||
workspaceMemberships: user.workspaceMemberships.map((m) => ({
|
||||
workspaceId: m.workspaceId,
|
||||
workspaceName: m.workspace.name,
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async createWorkspace(dto: CreateWorkspaceDto): Promise<AdminWorkspaceResponse> {
|
||||
const owner = await this.prisma.user.findUnique({ where: { id: dto.ownerId } });
|
||||
if (!owner) {
|
||||
throw new NotFoundException(`User ${dto.ownerId} not found`);
|
||||
}
|
||||
|
||||
const workspace = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.workspace.create({
|
||||
data: {
|
||||
name: dto.name,
|
||||
ownerId: dto.ownerId,
|
||||
settings: dto.settings ? (dto.settings as Prisma.InputJsonValue) : {},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId: created.id,
|
||||
userId: dto.ownerId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
this.logger.log(`Workspace created: ${workspace.id} with owner ${dto.ownerId}`);
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
settings: workspace.settings as Record<string, unknown>,
|
||||
createdAt: workspace.createdAt,
|
||||
updatedAt: workspace.updatedAt,
|
||||
memberCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
async updateWorkspace(
|
||||
id: string,
|
||||
dto: { name?: string; settings?: Record<string, unknown> }
|
||||
): Promise<AdminWorkspaceResponse> {
|
||||
const existing = await this.prisma.workspace.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Workspace ${id} not found`);
|
||||
}
|
||||
|
||||
const data: Prisma.WorkspaceUpdateInput = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
data.name = dto.name;
|
||||
}
|
||||
if (dto.settings !== undefined) {
|
||||
data.settings = dto.settings as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
const workspace = await this.prisma.workspace.update({
|
||||
where: { id },
|
||||
data,
|
||||
include: {
|
||||
_count: { select: { members: true } },
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Workspace updated: ${id}`);
|
||||
|
||||
return {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
ownerId: workspace.ownerId,
|
||||
settings: workspace.settings as Record<string, unknown>,
|
||||
createdAt: workspace.createdAt,
|
||||
updatedAt: workspace.updatedAt,
|
||||
memberCount: workspace._count.members,
|
||||
};
|
||||
}
|
||||
}
|
||||
15
apps/api/src/admin/dto/create-workspace.dto.ts
Normal file
15
apps/api/src/admin/dto/create-workspace.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IsObject, IsOptional, IsString, IsUUID, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class CreateWorkspaceDto {
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsUUID("4", { message: "ownerId must be a valid UUID" })
|
||||
ownerId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: "settings must be an object" })
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
20
apps/api/src/admin/dto/invite-user.dto.ts
Normal file
20
apps/api/src/admin/dto/invite-user.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import { IsEmail, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
|
||||
|
||||
export class InviteUserDto {
|
||||
@IsEmail({}, { message: "email must be a valid email address" })
|
||||
email!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
|
||||
workspaceId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||
role?: WorkspaceMemberRole;
|
||||
}
|
||||
15
apps/api/src/admin/dto/manage-member.dto.ts
Normal file
15
apps/api/src/admin/dto/manage-member.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import { IsEnum, IsUUID } from "class-validator";
|
||||
|
||||
export class AddMemberDto {
|
||||
@IsUUID("4", { message: "userId must be a valid UUID" })
|
||||
userId!: string;
|
||||
|
||||
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||
role!: WorkspaceMemberRole;
|
||||
}
|
||||
|
||||
export class UpdateMemberRoleDto {
|
||||
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||
role!: WorkspaceMemberRole;
|
||||
}
|
||||
17
apps/api/src/admin/dto/query-users.dto.ts
Normal file
17
apps/api/src/admin/dto/query-users.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IsInt, IsOptional, Max, Min } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
|
||||
export class QueryUsersDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "page must be an integer" })
|
||||
@Min(1, { message: "page must be at least 1" })
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "limit must be an integer" })
|
||||
@Min(1, { message: "limit must be at least 1" })
|
||||
@Max(100, { message: "limit must not exceed 100" })
|
||||
limit?: number;
|
||||
}
|
||||
27
apps/api/src/admin/dto/update-user.dto.ts
Normal file
27
apps/api/src/admin/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
export class UpdateUserDto {
|
||||
@IsOptional()
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString({}, { message: "deactivatedAt must be a valid ISO 8601 date string" })
|
||||
deactivatedAt?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: "emailVerified must be a boolean" })
|
||||
emailVerified?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: "preferences must be an object" })
|
||||
preferences?: Record<string, unknown>;
|
||||
}
|
||||
13
apps/api/src/admin/dto/update-workspace.dto.ts
Normal file
13
apps/api/src/admin/dto/update-workspace.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsObject, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class UpdateWorkspaceDto {
|
||||
@IsOptional()
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject({ message: "settings must be an object" })
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
49
apps/api/src/admin/types/admin.types.ts
Normal file
49
apps/api/src/admin/types/admin.types.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { WorkspaceMemberRole } from "@prisma/client";
|
||||
|
||||
export interface AdminUserResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
image: string | null;
|
||||
createdAt: Date;
|
||||
deactivatedAt: Date | null;
|
||||
isLocalAuth: boolean;
|
||||
invitedAt: Date | null;
|
||||
invitedBy: string | null;
|
||||
workspaceMemberships: WorkspaceMembershipResponse[];
|
||||
}
|
||||
|
||||
export interface WorkspaceMembershipResponse {
|
||||
workspaceId: string;
|
||||
workspaceName: string;
|
||||
role: WorkspaceMemberRole;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
meta: {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface InvitationResponse {
|
||||
userId: string;
|
||||
invitationToken: string;
|
||||
email: string;
|
||||
invitedAt: Date;
|
||||
}
|
||||
|
||||
export interface AdminWorkspaceResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
settings: Record<string, unknown>;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
memberCount: number;
|
||||
}
|
||||
@@ -39,6 +39,13 @@ import { FederationModule } from "./federation/federation.module";
|
||||
import { CredentialsModule } from "./credentials/credentials.module";
|
||||
import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||
import { SpeechModule } from "./speech/speech.module";
|
||||
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||
import { TerminalModule } from "./terminal/terminal.module";
|
||||
import { PersonalitiesModule } from "./personalities/personalities.module";
|
||||
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
||||
import { AdminModule } from "./admin/admin.module";
|
||||
import { TeamsModule } from "./teams/teams.module";
|
||||
import { ImportModule } from "./import/import.module";
|
||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||
|
||||
@Module({
|
||||
@@ -101,6 +108,13 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
||||
CredentialsModule,
|
||||
MosaicTelemetryModule,
|
||||
SpeechModule,
|
||||
DashboardModule,
|
||||
TerminalModule,
|
||||
PersonalitiesModule,
|
||||
WorkspacesModule,
|
||||
AdminModule,
|
||||
TeamsModule,
|
||||
ImportModule,
|
||||
],
|
||||
controllers: [AppController, CsrfController],
|
||||
providers: [
|
||||
|
||||
@@ -254,6 +254,10 @@ export function createAuth(prisma: PrismaClient) {
|
||||
enabled: true,
|
||||
},
|
||||
plugins: [...getOidcPlugins()],
|
||||
logger: {
|
||||
disabled: false,
|
||||
level: "error",
|
||||
},
|
||||
session: {
|
||||
expiresIn: 60 * 60 * 24 * 7, // 7 days absolute max
|
||||
updateAge: 60 * 60 * 2, // 2 hours — minimum session age before BetterAuth refreshes the expiry on next request
|
||||
|
||||
@@ -361,16 +361,13 @@ describe("AuthController", () => {
|
||||
});
|
||||
|
||||
describe("getProfile", () => {
|
||||
it("should return complete user profile with workspace fields", () => {
|
||||
it("should return complete user profile with identity fields", () => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
image: "https://example.com/avatar.jpg",
|
||||
emailVerified: true,
|
||||
workspaceId: "workspace-123",
|
||||
currentWorkspaceId: "workspace-456",
|
||||
workspaceRole: "admin",
|
||||
};
|
||||
|
||||
const result = controller.getProfile(mockUser);
|
||||
@@ -381,13 +378,10 @@ describe("AuthController", () => {
|
||||
name: mockUser.name,
|
||||
image: mockUser.image,
|
||||
emailVerified: mockUser.emailVerified,
|
||||
workspaceId: mockUser.workspaceId,
|
||||
currentWorkspaceId: mockUser.currentWorkspaceId,
|
||||
workspaceRole: mockUser.workspaceRole,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return user profile with optional fields undefined", () => {
|
||||
it("should return user profile with only required fields", () => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-123",
|
||||
email: "test@example.com",
|
||||
@@ -400,12 +394,11 @@ describe("AuthController", () => {
|
||||
id: mockUser.id,
|
||||
email: mockUser.email,
|
||||
name: mockUser.name,
|
||||
image: undefined,
|
||||
emailVerified: undefined,
|
||||
workspaceId: undefined,
|
||||
currentWorkspaceId: undefined,
|
||||
workspaceRole: undefined,
|
||||
});
|
||||
// Workspace fields are not included — served by GET /api/workspaces
|
||||
expect(result).not.toHaveProperty("workspaceId");
|
||||
expect(result).not.toHaveProperty("currentWorkspaceId");
|
||||
expect(result).not.toHaveProperty("workspaceRole");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -72,15 +72,10 @@ export class AuthController {
|
||||
if (user.emailVerified !== undefined) {
|
||||
profile.emailVerified = user.emailVerified;
|
||||
}
|
||||
if (user.workspaceId !== undefined) {
|
||||
profile.workspaceId = user.workspaceId;
|
||||
}
|
||||
if (user.currentWorkspaceId !== undefined) {
|
||||
profile.currentWorkspaceId = user.currentWorkspaceId;
|
||||
}
|
||||
if (user.workspaceRole !== undefined) {
|
||||
profile.workspaceRole = user.workspaceRole;
|
||||
}
|
||||
|
||||
// Workspace context is served by GET /api/workspaces, not the auth profile.
|
||||
// The deprecated workspaceId/currentWorkspaceId/workspaceRole fields on
|
||||
// AuthUser are never populated by BetterAuth and are omitted here.
|
||||
|
||||
return profile;
|
||||
}
|
||||
@@ -123,6 +118,14 @@ export class AuthController {
|
||||
|
||||
try {
|
||||
await handler(req, res);
|
||||
|
||||
// BetterAuth writes responses directly — catch silent 500s that bypass NestJS error handling
|
||||
if (res.statusCode >= 500) {
|
||||
this.logger.error(
|
||||
`BetterAuth returned ${String(res.statusCode)} for ${req.method} ${req.url} from ${clientIp}` +
|
||||
` — check container stdout for '# SERVER_ERROR' details`
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const stack = error instanceof Error ? error.stack : undefined;
|
||||
|
||||
@@ -3,11 +3,14 @@ import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthGuard } from "./guards/auth.guard";
|
||||
import { LocalAuthController } from "./local/local-auth.controller";
|
||||
import { LocalAuthService } from "./local/local-auth.service";
|
||||
import { LocalAuthEnabledGuard } from "./local/local-auth.guard";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, AuthGuard],
|
||||
controllers: [AuthController, LocalAuthController],
|
||||
providers: [AuthService, AuthGuard, LocalAuthService, LocalAuthEnabledGuard],
|
||||
exports: [AuthService, AuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
10
apps/api/src/auth/local/dto/local-login.dto.ts
Normal file
10
apps/api/src/auth/local/dto/local-login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { IsEmail, IsString, MinLength } from "class-validator";
|
||||
|
||||
export class LocalLoginDto {
|
||||
@IsEmail({}, { message: "email must be a valid email address" })
|
||||
email!: string;
|
||||
|
||||
@IsString({ message: "password must be a string" })
|
||||
@MinLength(1, { message: "password must not be empty" })
|
||||
password!: string;
|
||||
}
|
||||
20
apps/api/src/auth/local/dto/local-setup.dto.ts
Normal file
20
apps/api/src/auth/local/dto/local-setup.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { IsEmail, IsString, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
export class LocalSetupDto {
|
||||
@IsEmail({}, { message: "email must be a valid email address" })
|
||||
email!: string;
|
||||
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsString({ message: "password must be a string" })
|
||||
@MinLength(12, { message: "password must be at least 12 characters" })
|
||||
@MaxLength(128, { message: "password must not exceed 128 characters" })
|
||||
password!: string;
|
||||
|
||||
@IsString({ message: "setupToken must be a string" })
|
||||
@MinLength(1, { message: "setupToken must not be empty" })
|
||||
setupToken!: string;
|
||||
}
|
||||
232
apps/api/src/auth/local/local-auth.controller.spec.ts
Normal file
232
apps/api/src/auth/local/local-auth.controller.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import {
|
||||
NotFoundException,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
} from "@nestjs/common";
|
||||
import { LocalAuthController } from "./local-auth.controller";
|
||||
import { LocalAuthService } from "./local-auth.service";
|
||||
import { LocalAuthEnabledGuard } from "./local-auth.guard";
|
||||
|
||||
describe("LocalAuthController", () => {
|
||||
let controller: LocalAuthController;
|
||||
let localAuthService: LocalAuthService;
|
||||
|
||||
const mockLocalAuthService = {
|
||||
setup: vi.fn(),
|
||||
login: vi.fn(),
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
headers: { "user-agent": "TestAgent/1.0" },
|
||||
ip: "127.0.0.1",
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
};
|
||||
|
||||
const originalEnv = {
|
||||
ENABLE_LOCAL_AUTH: process.env.ENABLE_LOCAL_AUTH,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.ENABLE_LOCAL_AUTH = "true";
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [LocalAuthController],
|
||||
providers: [
|
||||
{
|
||||
provide: LocalAuthService,
|
||||
useValue: mockLocalAuthService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(LocalAuthEnabledGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<LocalAuthController>(LocalAuthController);
|
||||
localAuthService = module.get<LocalAuthService>(LocalAuthService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalEnv.ENABLE_LOCAL_AUTH !== undefined) {
|
||||
process.env.ENABLE_LOCAL_AUTH = originalEnv.ENABLE_LOCAL_AUTH;
|
||||
} else {
|
||||
delete process.env.ENABLE_LOCAL_AUTH;
|
||||
}
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
const setupDto = {
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
password: "securePassword123!",
|
||||
setupToken: "valid-token-123",
|
||||
};
|
||||
|
||||
const mockSetupResult = {
|
||||
user: {
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
isLocalAuth: true,
|
||||
createdAt: new Date("2026-02-28T00:00:00Z"),
|
||||
},
|
||||
session: {
|
||||
token: "session-token-abc",
|
||||
expiresAt: new Date("2026-03-07T00:00:00Z"),
|
||||
},
|
||||
};
|
||||
|
||||
it("should create a break-glass user and return user data with session", async () => {
|
||||
mockLocalAuthService.setup.mockResolvedValue(mockSetupResult);
|
||||
|
||||
const result = await controller.setup(setupDto, mockRequest as never);
|
||||
|
||||
expect(result).toEqual({
|
||||
user: mockSetupResult.user,
|
||||
session: mockSetupResult.session,
|
||||
});
|
||||
expect(mockLocalAuthService.setup).toHaveBeenCalledWith(
|
||||
"admin@example.com",
|
||||
"Break Glass Admin",
|
||||
"securePassword123!",
|
||||
"valid-token-123",
|
||||
"127.0.0.1",
|
||||
"TestAgent/1.0"
|
||||
);
|
||||
});
|
||||
|
||||
it("should extract client IP from x-forwarded-for header", async () => {
|
||||
mockLocalAuthService.setup.mockResolvedValue(mockSetupResult);
|
||||
const reqWithProxy = {
|
||||
...mockRequest,
|
||||
headers: {
|
||||
...mockRequest.headers,
|
||||
"x-forwarded-for": "203.0.113.50, 70.41.3.18",
|
||||
},
|
||||
};
|
||||
|
||||
await controller.setup(setupDto, reqWithProxy as never);
|
||||
|
||||
expect(mockLocalAuthService.setup).toHaveBeenCalledWith(
|
||||
expect.any(String) as string,
|
||||
expect.any(String) as string,
|
||||
expect.any(String) as string,
|
||||
expect.any(String) as string,
|
||||
"203.0.113.50",
|
||||
"TestAgent/1.0"
|
||||
);
|
||||
});
|
||||
|
||||
it("should propagate ForbiddenException from service", async () => {
|
||||
mockLocalAuthService.setup.mockRejectedValue(new ForbiddenException("Invalid setup token"));
|
||||
|
||||
await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow(
|
||||
ForbiddenException
|
||||
);
|
||||
});
|
||||
|
||||
it("should propagate ConflictException from service", async () => {
|
||||
mockLocalAuthService.setup.mockRejectedValue(
|
||||
new ConflictException("A user with this email already exists")
|
||||
);
|
||||
|
||||
await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow(
|
||||
ConflictException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
const loginDto = {
|
||||
email: "admin@example.com",
|
||||
password: "securePassword123!",
|
||||
};
|
||||
|
||||
const mockLoginResult = {
|
||||
user: {
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
},
|
||||
session: {
|
||||
token: "session-token-abc",
|
||||
expiresAt: new Date("2026-03-07T00:00:00Z"),
|
||||
},
|
||||
};
|
||||
|
||||
it("should authenticate and return user data with session", async () => {
|
||||
mockLocalAuthService.login.mockResolvedValue(mockLoginResult);
|
||||
|
||||
const result = await controller.login(loginDto, mockRequest as never);
|
||||
|
||||
expect(result).toEqual({
|
||||
user: mockLoginResult.user,
|
||||
session: mockLoginResult.session,
|
||||
});
|
||||
expect(mockLocalAuthService.login).toHaveBeenCalledWith(
|
||||
"admin@example.com",
|
||||
"securePassword123!",
|
||||
"127.0.0.1",
|
||||
"TestAgent/1.0"
|
||||
);
|
||||
});
|
||||
|
||||
it("should propagate UnauthorizedException from service", async () => {
|
||||
mockLocalAuthService.login.mockRejectedValue(
|
||||
new UnauthorizedException("Invalid email or password")
|
||||
);
|
||||
|
||||
await expect(controller.login(loginDto, mockRequest as never)).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("LocalAuthEnabledGuard", () => {
|
||||
let guard: LocalAuthEnabledGuard;
|
||||
|
||||
const originalEnv = process.env.ENABLE_LOCAL_AUTH;
|
||||
|
||||
beforeEach(() => {
|
||||
guard = new LocalAuthEnabledGuard();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv !== undefined) {
|
||||
process.env.ENABLE_LOCAL_AUTH = originalEnv;
|
||||
} else {
|
||||
delete process.env.ENABLE_LOCAL_AUTH;
|
||||
}
|
||||
});
|
||||
|
||||
it("should allow access when ENABLE_LOCAL_AUTH is true", () => {
|
||||
process.env.ENABLE_LOCAL_AUTH = "true";
|
||||
|
||||
expect(guard.canActivate()).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is not set", () => {
|
||||
delete process.env.ENABLE_LOCAL_AUTH;
|
||||
|
||||
expect(() => guard.canActivate()).toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is false", () => {
|
||||
process.env.ENABLE_LOCAL_AUTH = "false";
|
||||
|
||||
expect(() => guard.canActivate()).toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is empty", () => {
|
||||
process.env.ENABLE_LOCAL_AUTH = "";
|
||||
|
||||
expect(() => guard.canActivate()).toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
81
apps/api/src/auth/local/local-auth.controller.ts
Normal file
81
apps/api/src/auth/local/local-auth.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
UseGuards,
|
||||
Req,
|
||||
Logger,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { Throttle } from "@nestjs/throttler";
|
||||
import type { Request as ExpressRequest } from "express";
|
||||
import { SkipCsrf } from "../../common/decorators/skip-csrf.decorator";
|
||||
import { LocalAuthService } from "./local-auth.service";
|
||||
import { LocalAuthEnabledGuard } from "./local-auth.guard";
|
||||
import { LocalLoginDto } from "./dto/local-login.dto";
|
||||
import { LocalSetupDto } from "./dto/local-setup.dto";
|
||||
|
||||
@Controller("auth/local")
|
||||
@UseGuards(LocalAuthEnabledGuard)
|
||||
export class LocalAuthController {
|
||||
private readonly logger = new Logger(LocalAuthController.name);
|
||||
|
||||
constructor(private readonly localAuthService: LocalAuthService) {}
|
||||
|
||||
/**
|
||||
* First-time break-glass user creation.
|
||||
* Requires BREAKGLASS_SETUP_TOKEN from environment.
|
||||
*/
|
||||
@Post("setup")
|
||||
@SkipCsrf()
|
||||
@Throttle({ strict: { limit: 5, ttl: 60000 } })
|
||||
async setup(@Body() dto: LocalSetupDto, @Req() req: ExpressRequest) {
|
||||
const ipAddress = this.getClientIp(req);
|
||||
const userAgent = req.headers["user-agent"];
|
||||
|
||||
this.logger.log(`Break-glass setup attempt from ${ipAddress}`);
|
||||
|
||||
const result = await this.localAuthService.setup(
|
||||
dto.email,
|
||||
dto.name,
|
||||
dto.password,
|
||||
dto.setupToken,
|
||||
ipAddress,
|
||||
userAgent
|
||||
);
|
||||
|
||||
return {
|
||||
user: result.user,
|
||||
session: result.session,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Break-glass login with email + password.
|
||||
*/
|
||||
@Post("login")
|
||||
@SkipCsrf()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Throttle({ strict: { limit: 10, ttl: 60000 } })
|
||||
async login(@Body() dto: LocalLoginDto, @Req() req: ExpressRequest) {
|
||||
const ipAddress = this.getClientIp(req);
|
||||
const userAgent = req.headers["user-agent"];
|
||||
|
||||
const result = await this.localAuthService.login(dto.email, dto.password, ipAddress, userAgent);
|
||||
|
||||
return {
|
||||
user: result.user,
|
||||
session: result.session,
|
||||
};
|
||||
}
|
||||
|
||||
private getClientIp(req: ExpressRequest): string {
|
||||
const forwardedFor = req.headers["x-forwarded-for"];
|
||||
if (forwardedFor) {
|
||||
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
||||
return ips?.split(",")[0]?.trim() ?? "unknown";
|
||||
}
|
||||
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
||||
}
|
||||
}
|
||||
15
apps/api/src/auth/local/local-auth.guard.ts
Normal file
15
apps/api/src/auth/local/local-auth.guard.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Injectable, CanActivate, NotFoundException } from "@nestjs/common";
|
||||
|
||||
/**
|
||||
* Guard that checks if local authentication is enabled via ENABLE_LOCAL_AUTH env var.
|
||||
* Returns 404 when disabled so endpoints are invisible to callers.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LocalAuthEnabledGuard implements CanActivate {
|
||||
canActivate(): boolean {
|
||||
if (process.env.ENABLE_LOCAL_AUTH !== "true") {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
389
apps/api/src/auth/local/local-auth.service.spec.ts
Normal file
389
apps/api/src/auth/local/local-auth.service.spec.ts
Normal file
@@ -0,0 +1,389 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import {
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
InternalServerErrorException,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { hash } from "bcryptjs";
|
||||
import { LocalAuthService } from "./local-auth.service";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
describe("LocalAuthService", () => {
|
||||
let service: LocalAuthService;
|
||||
|
||||
const mockTxSession = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTxWorkspace = {
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTxWorkspaceMember = {
|
||||
create: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTxUser = {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
};
|
||||
|
||||
const mockTx = {
|
||||
user: mockTxUser,
|
||||
workspace: mockTxWorkspace,
|
||||
workspaceMember: mockTxWorkspaceMember,
|
||||
session: mockTxSession,
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi
|
||||
.fn()
|
||||
.mockImplementation((fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
|
||||
};
|
||||
|
||||
const originalEnv = {
|
||||
BREAKGLASS_SETUP_TOKEN: process.env.BREAKGLASS_SETUP_TOKEN,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LocalAuthService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LocalAuthService>(LocalAuthService);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
if (originalEnv.BREAKGLASS_SETUP_TOKEN !== undefined) {
|
||||
process.env.BREAKGLASS_SETUP_TOKEN = originalEnv.BREAKGLASS_SETUP_TOKEN;
|
||||
} else {
|
||||
delete process.env.BREAKGLASS_SETUP_TOKEN;
|
||||
}
|
||||
});
|
||||
|
||||
describe("setup", () => {
|
||||
const validSetupArgs = {
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
password: "securePassword123!",
|
||||
setupToken: "valid-token-123",
|
||||
};
|
||||
|
||||
const mockCreatedUser = {
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
isLocalAuth: true,
|
||||
createdAt: new Date("2026-02-28T00:00:00Z"),
|
||||
};
|
||||
|
||||
const mockWorkspace = {
|
||||
id: "workspace-uuid-123",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.BREAKGLASS_SETUP_TOKEN = "valid-token-123";
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
mockTxUser.create.mockResolvedValue(mockCreatedUser);
|
||||
mockTxWorkspace.findFirst.mockResolvedValue(mockWorkspace);
|
||||
mockTxWorkspaceMember.create.mockResolvedValue({});
|
||||
mockTxSession.create.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("should create a local auth user with hashed password", async () => {
|
||||
const result = await service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
);
|
||||
|
||||
expect(result.user).toEqual(mockCreatedUser);
|
||||
expect(result.session.token).toBeDefined();
|
||||
expect(result.session.token.length).toBeGreaterThan(0);
|
||||
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
||||
expect(result.session.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
|
||||
expect(mockTxUser.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
isLocalAuth: true,
|
||||
emailVerified: true,
|
||||
passwordHash: expect.any(String) as string,
|
||||
}),
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
isLocalAuth: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should assign OWNER role on default workspace", async () => {
|
||||
await service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
);
|
||||
|
||||
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: "workspace-uuid-123",
|
||||
userId: "user-uuid-123",
|
||||
role: "OWNER",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a new workspace if none exists", async () => {
|
||||
mockTxWorkspace.findFirst.mockResolvedValue(null);
|
||||
mockTxWorkspace.create.mockResolvedValue({ id: "new-workspace-uuid" });
|
||||
|
||||
await service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
);
|
||||
|
||||
expect(mockTxWorkspace.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: "Default Workspace",
|
||||
ownerId: "user-uuid-123",
|
||||
settings: {},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: "new-workspace-uuid",
|
||||
userId: "user-uuid-123",
|
||||
role: "OWNER",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a BetterAuth-compatible session", async () => {
|
||||
await service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken,
|
||||
"192.168.1.1",
|
||||
"TestAgent/1.0"
|
||||
);
|
||||
|
||||
expect(mockTxSession.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "user-uuid-123",
|
||||
token: expect.any(String) as string,
|
||||
expiresAt: expect.any(Date) as Date,
|
||||
ipAddress: "192.168.1.1",
|
||||
userAgent: "TestAgent/1.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject when BREAKGLASS_SETUP_TOKEN is not set", async () => {
|
||||
delete process.env.BREAKGLASS_SETUP_TOKEN;
|
||||
|
||||
await expect(
|
||||
service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
)
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should reject when BREAKGLASS_SETUP_TOKEN is empty", async () => {
|
||||
process.env.BREAKGLASS_SETUP_TOKEN = "";
|
||||
|
||||
await expect(
|
||||
service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
)
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should reject when setup token does not match", async () => {
|
||||
await expect(
|
||||
service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
"wrong-token"
|
||||
)
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it("should reject when email already exists", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
id: "existing-user",
|
||||
email: "admin@example.com",
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
)
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("should return session token and expiry", async () => {
|
||||
const result = await service.setup(
|
||||
validSetupArgs.email,
|
||||
validSetupArgs.name,
|
||||
validSetupArgs.password,
|
||||
validSetupArgs.setupToken
|
||||
);
|
||||
|
||||
expect(typeof result.session.token).toBe("string");
|
||||
expect(result.session.token.length).toBe(64); // 32 bytes hex
|
||||
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe("login", () => {
|
||||
const validPasswordHash = "$2a$12$LJ3m4ys3Lz/YgP7xYz5k5uU6b5F6X1234567890abcdefghijkl";
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a real bcrypt hash for testing
|
||||
const realHash = await hash("securePassword123!", 4); // Low rounds for test speed
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
isLocalAuth: true,
|
||||
passwordHash: realHash,
|
||||
deactivatedAt: null,
|
||||
});
|
||||
mockPrismaService.session.create.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("should authenticate a valid local auth user", async () => {
|
||||
const result = await service.login("admin@example.com", "securePassword123!");
|
||||
|
||||
expect(result.user).toEqual({
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Break Glass Admin",
|
||||
});
|
||||
expect(result.session.token).toBeDefined();
|
||||
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("should create a session with ip and user agent", async () => {
|
||||
await service.login("admin@example.com", "securePassword123!", "10.0.0.1", "Mozilla/5.0");
|
||||
|
||||
expect(mockPrismaService.session.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
userId: "user-uuid-123",
|
||||
token: expect.any(String) as string,
|
||||
expiresAt: expect.any(Date) as Date,
|
||||
ipAddress: "10.0.0.1",
|
||||
userAgent: "Mozilla/5.0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should reject when user does not exist", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(service.login("nonexistent@example.com", "password123456")).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject when user is not a local auth user", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "OIDC User",
|
||||
isLocalAuth: false,
|
||||
passwordHash: null,
|
||||
deactivatedAt: null,
|
||||
});
|
||||
|
||||
await expect(service.login("admin@example.com", "password123456")).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject when user is deactivated", async () => {
|
||||
const realHash = await hash("securePassword123!", 4);
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Deactivated User",
|
||||
isLocalAuth: true,
|
||||
passwordHash: realHash,
|
||||
deactivatedAt: new Date("2026-01-01"),
|
||||
});
|
||||
|
||||
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
|
||||
new UnauthorizedException("Account has been deactivated")
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject when password is incorrect", async () => {
|
||||
await expect(service.login("admin@example.com", "wrongPassword123!")).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw InternalServerError when local auth user has no password hash", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||
id: "user-uuid-123",
|
||||
email: "admin@example.com",
|
||||
name: "Broken User",
|
||||
isLocalAuth: true,
|
||||
passwordHash: null,
|
||||
deactivatedAt: null,
|
||||
});
|
||||
|
||||
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
|
||||
InternalServerErrorException
|
||||
);
|
||||
});
|
||||
|
||||
it("should not reveal whether email exists in error messages", async () => {
|
||||
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||
|
||||
try {
|
||||
await service.login("nonexistent@example.com", "password123456");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(UnauthorizedException);
|
||||
expect((error as UnauthorizedException).message).toBe("Invalid email or password");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
230
apps/api/src/auth/local/local-auth.service.ts
Normal file
230
apps/api/src/auth/local/local-auth.service.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
ForbiddenException,
|
||||
UnauthorizedException,
|
||||
ConflictException,
|
||||
InternalServerErrorException,
|
||||
} from "@nestjs/common";
|
||||
import { WorkspaceMemberRole } from "@prisma/client";
|
||||
import { hash, compare } from "bcryptjs";
|
||||
import { randomBytes, timingSafeEqual } from "crypto";
|
||||
import { PrismaService } from "../../prisma/prisma.service";
|
||||
|
||||
const BCRYPT_ROUNDS = 12;
|
||||
|
||||
/** Session expiry: 7 days (matches BetterAuth config in auth.config.ts) */
|
||||
const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
interface SetupResult {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
isLocalAuth: boolean;
|
||||
createdAt: Date;
|
||||
};
|
||||
session: {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
interface LoginResult {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
session: {
|
||||
token: string;
|
||||
expiresAt: Date;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocalAuthService {
|
||||
private readonly logger = new Logger(LocalAuthService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* First-time break-glass user creation.
|
||||
* Validates the setup token, creates a local auth user with bcrypt-hashed password,
|
||||
* and assigns OWNER role on the default workspace.
|
||||
*/
|
||||
async setup(
|
||||
email: string,
|
||||
name: string,
|
||||
password: string,
|
||||
setupToken: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<SetupResult> {
|
||||
this.validateSetupToken(setupToken);
|
||||
|
||||
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||
if (existing) {
|
||||
throw new ConflictException("A user with this email already exists");
|
||||
}
|
||||
|
||||
const passwordHash = await hash(password, BCRYPT_ROUNDS);
|
||||
|
||||
const result = await this.prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
name,
|
||||
isLocalAuth: true,
|
||||
passwordHash,
|
||||
emailVerified: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
isLocalAuth: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Find or create a default workspace and assign OWNER role
|
||||
await this.assignDefaultWorkspace(tx, user.id);
|
||||
|
||||
// Create a BetterAuth-compatible session
|
||||
const session = await this.createSession(tx, user.id, ipAddress, userAgent);
|
||||
|
||||
return { user, session };
|
||||
});
|
||||
|
||||
this.logger.log(`Break-glass user created: ${email}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Break-glass login: verify email + password against bcrypt hash.
|
||||
* Only works for users with isLocalAuth=true.
|
||||
*/
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<LoginResult> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
isLocalAuth: true,
|
||||
passwordHash: true,
|
||||
deactivatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.isLocalAuth) {
|
||||
throw new UnauthorizedException("Invalid email or password");
|
||||
}
|
||||
|
||||
if (user.deactivatedAt) {
|
||||
throw new UnauthorizedException("Account has been deactivated");
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
this.logger.error(`Local auth user ${email} has no password hash`);
|
||||
throw new InternalServerErrorException("Account configuration error");
|
||||
}
|
||||
|
||||
const passwordValid = await compare(password, user.passwordHash);
|
||||
if (!passwordValid) {
|
||||
throw new UnauthorizedException("Invalid email or password");
|
||||
}
|
||||
|
||||
const session = await this.createSession(this.prisma, user.id, ipAddress, userAgent);
|
||||
|
||||
this.logger.log(`Break-glass login: ${email}`);
|
||||
return {
|
||||
user: { id: user.id, email: user.email, name: user.name },
|
||||
session,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the setup token against the environment variable.
|
||||
*/
|
||||
private validateSetupToken(token: string): void {
|
||||
const expectedToken = process.env.BREAKGLASS_SETUP_TOKEN;
|
||||
|
||||
if (!expectedToken || expectedToken.trim() === "") {
|
||||
throw new ForbiddenException(
|
||||
"Break-glass setup is not configured. Set BREAKGLASS_SETUP_TOKEN environment variable."
|
||||
);
|
||||
}
|
||||
|
||||
const tokenBuffer = Buffer.from(token);
|
||||
const expectedBuffer = Buffer.from(expectedToken);
|
||||
if (
|
||||
tokenBuffer.length !== expectedBuffer.length ||
|
||||
!timingSafeEqual(tokenBuffer, expectedBuffer)
|
||||
) {
|
||||
this.logger.warn("Invalid break-glass setup token attempt");
|
||||
throw new ForbiddenException("Invalid setup token");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first workspace or create a default one, then assign OWNER role.
|
||||
*/
|
||||
private async assignDefaultWorkspace(
|
||||
tx: Parameters<Parameters<PrismaService["$transaction"]>[0]>[0],
|
||||
userId: string
|
||||
): Promise<void> {
|
||||
let workspace = await tx.workspace.findFirst({
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
workspace ??= await tx.workspace.create({
|
||||
data: {
|
||||
name: "Default Workspace",
|
||||
ownerId: userId,
|
||||
settings: {},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await tx.workspaceMember.create({
|
||||
data: {
|
||||
workspaceId: workspace.id,
|
||||
userId,
|
||||
role: WorkspaceMemberRole.OWNER,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a BetterAuth-compatible session record.
|
||||
*/
|
||||
private async createSession(
|
||||
tx: { session: { create: typeof PrismaService.prototype.session.create } },
|
||||
userId: string,
|
||||
ipAddress?: string,
|
||||
userAgent?: string
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
||||
|
||||
await tx.session.create({
|
||||
data: {
|
||||
userId,
|
||||
token,
|
||||
expiresAt,
|
||||
ipAddress: ipAddress ?? null,
|
||||
userAgent: userAgent ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
@Controller("api/v1/csrf")
|
||||
@Controller("v1/csrf")
|
||||
export class CsrfController {
|
||||
constructor(private readonly csrfService: CsrfService) {}
|
||||
|
||||
|
||||
@@ -174,17 +174,19 @@ describe("CsrfGuard", () => {
|
||||
});
|
||||
|
||||
describe("Session binding validation", () => {
|
||||
it("should reject when user is not authenticated", () => {
|
||||
it("should allow when user context is not yet available (global guard ordering)", () => {
|
||||
// CsrfGuard runs as APP_GUARD before per-controller AuthGuard,
|
||||
// so request.user may not be populated. Double-submit cookie match
|
||||
// is sufficient protection in this case.
|
||||
const token = generateValidToken("user-123");
|
||||
const context = createContext(
|
||||
"POST",
|
||||
{ "csrf-token": token },
|
||||
{ "x-csrf-token": token },
|
||||
false
|
||||
// No userId - unauthenticated
|
||||
// No userId - AuthGuard hasn't run yet
|
||||
);
|
||||
expect(() => guard.canActivate(context)).toThrow(ForbiddenException);
|
||||
expect(() => guard.canActivate(context)).toThrow("CSRF validation requires authentication");
|
||||
expect(guard.canActivate(context)).toBe(true);
|
||||
});
|
||||
|
||||
it("should reject token from different session", () => {
|
||||
|
||||
@@ -89,30 +89,30 @@ export class CsrfGuard implements CanActivate {
|
||||
throw new ForbiddenException("CSRF token mismatch");
|
||||
}
|
||||
|
||||
// Validate session binding via HMAC
|
||||
// Validate session binding via HMAC when user context is available.
|
||||
// CsrfGuard is a global guard (APP_GUARD) that runs before per-controller
|
||||
// AuthGuard, so request.user may not be populated yet. In that case, the
|
||||
// double-submit cookie match above is sufficient CSRF protection.
|
||||
const userId = request.user?.id;
|
||||
if (!userId) {
|
||||
this.logger.warn({
|
||||
event: "CSRF_NO_USER_CONTEXT",
|
||||
if (userId) {
|
||||
if (!this.csrfService.validateToken(cookieToken, userId)) {
|
||||
this.logger.warn({
|
||||
event: "CSRF_SESSION_BINDING_INVALID",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
securityEvent: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw new ForbiddenException("CSRF token not bound to session");
|
||||
}
|
||||
} else {
|
||||
this.logger.debug({
|
||||
event: "CSRF_SKIP_SESSION_BINDING",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
securityEvent: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
reason: "User context not yet available (global guard runs before AuthGuard)",
|
||||
});
|
||||
|
||||
throw new ForbiddenException("CSRF validation requires authentication");
|
||||
}
|
||||
|
||||
if (!this.csrfService.validateToken(cookieToken, userId)) {
|
||||
this.logger.warn({
|
||||
event: "CSRF_SESSION_BINDING_INVALID",
|
||||
method: request.method,
|
||||
path: request.path,
|
||||
securityEvent: true,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
throw new ForbiddenException("CSRF token not bound to session");
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
@@ -110,10 +110,10 @@ export class WorkspaceGuard implements CanActivate {
|
||||
return paramWorkspaceId;
|
||||
}
|
||||
|
||||
// 3. Check request body
|
||||
const bodyWorkspaceId = request.body.workspaceId;
|
||||
if (typeof bodyWorkspaceId === "string") {
|
||||
return bodyWorkspaceId;
|
||||
// 3. Check request body (body may be undefined for GET requests despite Express typings)
|
||||
const body = request.body as Record<string, unknown> | undefined;
|
||||
if (body && typeof body.workspaceId === "string") {
|
||||
return body.workspaceId;
|
||||
}
|
||||
|
||||
// 4. Check query string (backward compatibility for existing clients)
|
||||
|
||||
@@ -270,7 +270,7 @@ describe("sanitizeForLogging", () => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
expect(result.password).toBe("[REDACTED]");
|
||||
expect(duration).toBeLessThan(100); // Should complete in under 100ms
|
||||
expect(duration).toBeLessThan(500); // Should complete in under 500ms
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -245,7 +245,7 @@ describe("CoordinatorIntegrationController - Rate Limiting", () => {
|
||||
.set("X-API-Key", "test-coordinator-key");
|
||||
|
||||
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||
});
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
describe("Per-API-Key Rate Limiting", () => {
|
||||
|
||||
143
apps/api/src/dashboard/dashboard.controller.spec.ts
Normal file
143
apps/api/src/dashboard/dashboard.controller.spec.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { DashboardController } from "./dashboard.controller";
|
||||
import { DashboardService } from "./dashboard.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import type { DashboardSummaryDto } from "./dto";
|
||||
|
||||
describe("DashboardController", () => {
|
||||
let controller: DashboardController;
|
||||
let service: DashboardService;
|
||||
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
|
||||
const mockSummary: DashboardSummaryDto = {
|
||||
metrics: {
|
||||
activeAgents: 3,
|
||||
tasksCompleted: 12,
|
||||
totalTasks: 25,
|
||||
tasksInProgress: 5,
|
||||
activeProjects: 4,
|
||||
errorRate: 2.5,
|
||||
},
|
||||
recentActivity: [
|
||||
{
|
||||
id: "550e8400-e29b-41d4-a716-446655440010",
|
||||
action: "CREATED",
|
||||
entityType: "TASK",
|
||||
entityId: "550e8400-e29b-41d4-a716-446655440011",
|
||||
details: { title: "New task" },
|
||||
userId: "550e8400-e29b-41d4-a716-446655440002",
|
||||
createdAt: "2026-02-22T12:00:00.000Z",
|
||||
},
|
||||
],
|
||||
activeJobs: [
|
||||
{
|
||||
id: "550e8400-e29b-41d4-a716-446655440020",
|
||||
type: "code-task",
|
||||
status: "RUNNING",
|
||||
progressPercent: 45,
|
||||
createdAt: "2026-02-22T11:00:00.000Z",
|
||||
updatedAt: "2026-02-22T11:30:00.000Z",
|
||||
steps: [
|
||||
{
|
||||
id: "550e8400-e29b-41d4-a716-446655440030",
|
||||
name: "Setup",
|
||||
status: "COMPLETED",
|
||||
phase: "SETUP",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tokenBudget: [
|
||||
{
|
||||
model: "agent-1",
|
||||
used: 5000,
|
||||
limit: 10000,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDashboardService = {
|
||||
getSummary: vi.fn(),
|
||||
};
|
||||
|
||||
const mockAuthGuard = {
|
||||
canActivate: vi.fn(() => true),
|
||||
};
|
||||
|
||||
const mockWorkspaceGuard = {
|
||||
canActivate: vi.fn(() => true),
|
||||
};
|
||||
|
||||
const mockPermissionGuard = {
|
||||
canActivate: vi.fn(() => true),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [DashboardController],
|
||||
providers: [
|
||||
{
|
||||
provide: DashboardService,
|
||||
useValue: mockDashboardService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue(mockAuthGuard)
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue(mockWorkspaceGuard)
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue(mockPermissionGuard)
|
||||
.compile();
|
||||
|
||||
controller = module.get<DashboardController>(DashboardController);
|
||||
service = module.get<DashboardService>(DashboardService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("getSummary", () => {
|
||||
it("should return dashboard summary for workspace", async () => {
|
||||
mockDashboardService.getSummary.mockResolvedValue(mockSummary);
|
||||
|
||||
const result = await controller.getSummary(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockSummary);
|
||||
expect(service.getSummary).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
});
|
||||
|
||||
it("should return empty arrays when no data exists", async () => {
|
||||
const emptySummary: DashboardSummaryDto = {
|
||||
metrics: {
|
||||
activeAgents: 0,
|
||||
tasksCompleted: 0,
|
||||
totalTasks: 0,
|
||||
tasksInProgress: 0,
|
||||
activeProjects: 0,
|
||||
errorRate: 0,
|
||||
},
|
||||
recentActivity: [],
|
||||
activeJobs: [],
|
||||
tokenBudget: [],
|
||||
};
|
||||
|
||||
mockDashboardService.getSummary.mockResolvedValue(emptySummary);
|
||||
|
||||
const result = await controller.getSummary(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(emptySummary);
|
||||
expect(result.metrics.errorRate).toBe(0);
|
||||
expect(result.recentActivity).toHaveLength(0);
|
||||
expect(result.activeJobs).toHaveLength(0);
|
||||
expect(result.tokenBudget).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
35
apps/api/src/dashboard/dashboard.controller.ts
Normal file
35
apps/api/src/dashboard/dashboard.controller.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Controller, Get, UseGuards, BadRequestException } from "@nestjs/common";
|
||||
import { DashboardService } from "./dashboard.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import type { DashboardSummaryDto } from "./dto";
|
||||
|
||||
/**
|
||||
* Controller for dashboard endpoints.
|
||||
* Returns aggregated summary data for the workspace dashboard.
|
||||
*
|
||||
* Guards are applied in order:
|
||||
* 1. AuthGuard - Verifies user authentication
|
||||
* 2. WorkspaceGuard - Validates workspace access and sets RLS context
|
||||
* 3. PermissionGuard - Checks role-based permissions
|
||||
*/
|
||||
@Controller("dashboard")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class DashboardController {
|
||||
constructor(private readonly dashboardService: DashboardService) {}
|
||||
|
||||
/**
|
||||
* GET /api/dashboard/summary
|
||||
* Returns aggregated metrics, recent activity, active jobs, and token budgets
|
||||
* Requires: Any workspace member (including GUEST)
|
||||
*/
|
||||
@Get("summary")
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async getSummary(@Workspace() workspaceId: string | undefined): Promise<DashboardSummaryDto> {
|
||||
if (!workspaceId) {
|
||||
throw new BadRequestException("Workspace context required");
|
||||
}
|
||||
return this.dashboardService.getSummary(workspaceId);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/dashboard/dashboard.module.ts
Normal file
13
apps/api/src/dashboard/dashboard.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { DashboardController } from "./dashboard.controller";
|
||||
import { DashboardService } from "./dashboard.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService],
|
||||
exports: [DashboardService],
|
||||
})
|
||||
export class DashboardModule {}
|
||||
187
apps/api/src/dashboard/dashboard.service.ts
Normal file
187
apps/api/src/dashboard/dashboard.service.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { AgentStatus, ProjectStatus, RunnerJobStatus, TaskStatus } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import type {
|
||||
DashboardSummaryDto,
|
||||
ActiveJobDto,
|
||||
RecentActivityDto,
|
||||
TokenBudgetEntryDto,
|
||||
} from "./dto";
|
||||
|
||||
/**
|
||||
* Service for aggregating dashboard summary data.
|
||||
* Executes all queries in parallel to minimize latency.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DashboardService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Get aggregated dashboard summary for a workspace
|
||||
*/
|
||||
async getSummary(workspaceId: string): Promise<DashboardSummaryDto> {
|
||||
const now = new Date();
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// Execute all queries in parallel
|
||||
const [
|
||||
activeAgents,
|
||||
tasksCompleted,
|
||||
totalTasks,
|
||||
tasksInProgress,
|
||||
activeProjects,
|
||||
failedJobsLast24h,
|
||||
totalJobsLast24h,
|
||||
recentActivityRows,
|
||||
activeJobRows,
|
||||
tokenBudgetRows,
|
||||
] = await Promise.all([
|
||||
// Active agents: IDLE, WORKING, WAITING
|
||||
this.prisma.agent.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: { in: [AgentStatus.IDLE, AgentStatus.WORKING, AgentStatus.WAITING] },
|
||||
},
|
||||
}),
|
||||
|
||||
// Tasks completed
|
||||
this.prisma.task.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: TaskStatus.COMPLETED,
|
||||
},
|
||||
}),
|
||||
|
||||
// Total tasks
|
||||
this.prisma.task.count({
|
||||
where: { workspaceId },
|
||||
}),
|
||||
|
||||
// Tasks in progress
|
||||
this.prisma.task.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: TaskStatus.IN_PROGRESS,
|
||||
},
|
||||
}),
|
||||
|
||||
// Active projects
|
||||
this.prisma.project.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: ProjectStatus.ACTIVE,
|
||||
},
|
||||
}),
|
||||
|
||||
// Failed jobs in last 24h (for error rate)
|
||||
this.prisma.runnerJob.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: RunnerJobStatus.FAILED,
|
||||
createdAt: { gte: oneDayAgo },
|
||||
},
|
||||
}),
|
||||
|
||||
// Total jobs in last 24h (for error rate)
|
||||
this.prisma.runnerJob.count({
|
||||
where: {
|
||||
workspaceId,
|
||||
createdAt: { gte: oneDayAgo },
|
||||
},
|
||||
}),
|
||||
|
||||
// Recent activity: last 10 entries
|
||||
this.prisma.activityLog.findMany({
|
||||
where: { workspaceId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 10,
|
||||
}),
|
||||
|
||||
// Active jobs: PENDING, QUEUED, RUNNING with steps
|
||||
this.prisma.runnerJob.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: {
|
||||
in: [RunnerJobStatus.PENDING, RunnerJobStatus.QUEUED, RunnerJobStatus.RUNNING],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
steps: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
status: true,
|
||||
phase: true,
|
||||
},
|
||||
orderBy: { ordinal: "asc" },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
|
||||
// Token budgets for workspace (active, not yet completed)
|
||||
this.prisma.tokenBudget.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
completedAt: null,
|
||||
},
|
||||
select: {
|
||||
agentId: true,
|
||||
totalTokensUsed: true,
|
||||
allocatedTokens: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Compute error rate
|
||||
const errorRate = totalJobsLast24h > 0 ? (failedJobsLast24h / totalJobsLast24h) * 100 : 0;
|
||||
|
||||
// Map recent activity
|
||||
const recentActivity: RecentActivityDto[] = recentActivityRows.map((row) => ({
|
||||
id: row.id,
|
||||
action: row.action,
|
||||
entityType: row.entityType,
|
||||
entityId: row.entityId,
|
||||
details: row.details as Record<string, unknown> | null,
|
||||
userId: row.userId,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
|
||||
// Map active jobs (RunnerJob lacks updatedAt; use startedAt or createdAt as proxy)
|
||||
const activeJobs: ActiveJobDto[] = activeJobRows.map((row) => ({
|
||||
id: row.id,
|
||||
type: row.type,
|
||||
status: row.status,
|
||||
progressPercent: row.progressPercent,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: (row.startedAt ?? row.createdAt).toISOString(),
|
||||
steps: row.steps.map((step) => ({
|
||||
id: step.id,
|
||||
name: step.name,
|
||||
status: step.status,
|
||||
phase: step.phase,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Map token budget entries
|
||||
const tokenBudget: TokenBudgetEntryDto[] = tokenBudgetRows.map((row) => ({
|
||||
model: row.agentId,
|
||||
used: row.totalTokensUsed,
|
||||
limit: row.allocatedTokens,
|
||||
}));
|
||||
|
||||
return {
|
||||
metrics: {
|
||||
activeAgents,
|
||||
tasksCompleted,
|
||||
totalTasks,
|
||||
tasksInProgress,
|
||||
activeProjects,
|
||||
errorRate: Math.round(errorRate * 100) / 100,
|
||||
},
|
||||
recentActivity,
|
||||
activeJobs,
|
||||
tokenBudget,
|
||||
};
|
||||
}
|
||||
}
|
||||
53
apps/api/src/dashboard/dto/dashboard-summary.dto.ts
Normal file
53
apps/api/src/dashboard/dto/dashboard-summary.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Dashboard Summary DTO
|
||||
* Defines the response shape for the dashboard summary endpoint.
|
||||
*/
|
||||
|
||||
export class DashboardMetricsDto {
|
||||
activeAgents!: number;
|
||||
tasksCompleted!: number;
|
||||
totalTasks!: number;
|
||||
tasksInProgress!: number;
|
||||
activeProjects!: number;
|
||||
errorRate!: number;
|
||||
}
|
||||
|
||||
export class RecentActivityDto {
|
||||
id!: string;
|
||||
action!: string;
|
||||
entityType!: string;
|
||||
entityId!: string;
|
||||
details!: Record<string, unknown> | null;
|
||||
userId!: string;
|
||||
createdAt!: string;
|
||||
}
|
||||
|
||||
export class ActiveJobStepDto {
|
||||
id!: string;
|
||||
name!: string;
|
||||
status!: string;
|
||||
phase!: string;
|
||||
}
|
||||
|
||||
export class ActiveJobDto {
|
||||
id!: string;
|
||||
type!: string;
|
||||
status!: string;
|
||||
progressPercent!: number;
|
||||
createdAt!: string;
|
||||
updatedAt!: string;
|
||||
steps!: ActiveJobStepDto[];
|
||||
}
|
||||
|
||||
export class TokenBudgetEntryDto {
|
||||
model!: string;
|
||||
used!: number;
|
||||
limit!: number;
|
||||
}
|
||||
|
||||
export class DashboardSummaryDto {
|
||||
metrics!: DashboardMetricsDto;
|
||||
recentActivity!: RecentActivityDto[];
|
||||
activeJobs!: ActiveJobDto[];
|
||||
tokenBudget!: TokenBudgetEntryDto[];
|
||||
}
|
||||
1
apps/api/src/dashboard/dto/index.ts
Normal file
1
apps/api/src/dashboard/dto/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./dashboard-summary.dto";
|
||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { CommandMessageDetails, CommandResponse } from "./types/message.types";
|
||||
import type { FederationMessageStatus } from "@prisma/client";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class CommandController {
|
||||
private readonly logger = new Logger(CommandController.name);
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
IncomingEventAckDto,
|
||||
} from "./dto/event.dto";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class EventController {
|
||||
private readonly logger = new Logger(EventController.name);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
ValidateFederatedTokenDto,
|
||||
} from "./dto/federated-auth.dto";
|
||||
|
||||
@Controller("api/v1/federation/auth")
|
||||
@Controller("v1/federation/auth")
|
||||
export class FederationAuthController {
|
||||
private readonly logger = new Logger(FederationAuthController.name);
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "./dto/connection.dto";
|
||||
import { FederationConnectionStatus } from "@prisma/client";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class FederationController {
|
||||
private readonly logger = new Logger(FederationController.name);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { QueryMessageDetails, QueryResponse } from "./types/message.types";
|
||||
import type { FederationMessageStatus } from "@prisma/client";
|
||||
|
||||
@Controller("api/v1/federation")
|
||||
@Controller("v1/federation")
|
||||
export class QueryController {
|
||||
private readonly logger = new Logger(QueryController.name);
|
||||
|
||||
|
||||
89
apps/api/src/import/dto/import-project.dto.ts
Normal file
89
apps/api/src/import/dto/import-project.dto.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
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;
|
||||
}
|
||||
5
apps/api/src/import/dto/import-response.dto.ts
Normal file
5
apps/api/src/import/dto/import-response.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ImportResponseDto {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
errors: string[];
|
||||
}
|
||||
76
apps/api/src/import/dto/import-task.dto.ts
Normal file
76
apps/api/src/import/dto/import-task.dto.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
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;
|
||||
}
|
||||
3
apps/api/src/import/dto/index.ts
Normal file
3
apps/api/src/import/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { ImportTaskDto } from "./import-task.dto";
|
||||
export { ImportProjectDto } from "./import-project.dto";
|
||||
export type { ImportResponseDto } from "./import-response.dto";
|
||||
33
apps/api/src/import/import.controller.ts
Normal file
33
apps/api/src/import/import.controller.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/import/import.module.ts
Normal file
13
apps/api/src/import/import.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { ImportController } from "./import.controller";
|
||||
import { ImportService } from "./import.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [ImportController],
|
||||
providers: [ImportService],
|
||||
exports: [ImportService],
|
||||
})
|
||||
export class ImportModule {}
|
||||
251
apps/api/src/import/import.service.spec.ts
Normal file
251
apps/api/src/import/import.service.spec.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
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"),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
496
apps/api/src/import/import.service.ts
Normal file
496
apps/api/src/import/import.service.ts
Normal file
@@ -0,0 +1,496 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator";
|
||||
import { IsOptional, IsEnum, IsString, IsInt, IsIn, Min, Max } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { EntryStatus } from "@prisma/client";
|
||||
import { EntryStatus, Visibility } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* DTO for querying knowledge entries (list endpoint)
|
||||
@@ -10,10 +10,28 @@ export class EntryQueryDto {
|
||||
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||
status?: EntryStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(Visibility, { message: "visibility must be a valid Visibility" })
|
||||
visibility?: Visibility;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "tag must be a string" })
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "search must be a string" })
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["updatedAt", "createdAt", "title"], {
|
||||
message: "sortBy must be updatedAt, createdAt, or title",
|
||||
})
|
||||
sortBy?: "updatedAt" | "createdAt" | "title";
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" })
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "page must be an integer" })
|
||||
|
||||
@@ -48,6 +48,10 @@ export class KnowledgeService {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
if (query.visibility) {
|
||||
where.visibility = query.visibility;
|
||||
}
|
||||
|
||||
if (query.tag) {
|
||||
where.tags = {
|
||||
some: {
|
||||
@@ -58,6 +62,20 @@ export class KnowledgeService {
|
||||
};
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: query.search, mode: "insensitive" } },
|
||||
{ content: { contains: query.search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
// Build orderBy
|
||||
const sortField = query.sortBy ?? "updatedAt";
|
||||
const sortDirection = query.sortOrder ?? "desc";
|
||||
const orderBy: Prisma.KnowledgeEntryOrderByWithRelationInput = {
|
||||
[sortField]: sortDirection,
|
||||
};
|
||||
|
||||
// Get total count
|
||||
const total = await this.prisma.knowledgeEntry.count({ where });
|
||||
|
||||
@@ -71,9 +89,7 @@ export class KnowledgeService {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
orderBy,
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import { ValidationPipe } from "@nestjs/common";
|
||||
import { RequestMethod, ValidationPipe } from "@nestjs/common";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { AppModule } from "./app.module";
|
||||
import { getTrustedOrigins } from "./auth/auth.config";
|
||||
@@ -47,6 +47,16 @@ async function bootstrap() {
|
||||
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
|
||||
// Set global API prefix — all routes get /api/* except auth and health
|
||||
// Auth routes are excluded because BetterAuth expects /auth/* paths
|
||||
// Health is excluded because Docker healthchecks hit /health directly
|
||||
app.setGlobalPrefix("api", {
|
||||
exclude: [
|
||||
{ path: "health", method: RequestMethod.GET },
|
||||
{ path: "auth/(.*)", method: RequestMethod.ALL },
|
||||
],
|
||||
});
|
||||
|
||||
// Configure CORS for cookie-based authentication
|
||||
// Origin list is shared with BetterAuth trustedOrigins via getTrustedOrigins()
|
||||
const trustedOrigins = getTrustedOrigins();
|
||||
|
||||
@@ -1,59 +1,38 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from "class-validator";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for creating a new personality/assistant configuration
|
||||
* DTO for creating a new personality
|
||||
* Field names match the frontend API contract from @mosaic/shared Personality type.
|
||||
*/
|
||||
export class CreatePersonalityDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name!: string; // unique identifier slug
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
displayName!: string; // human-readable name
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
systemPrompt!: string;
|
||||
@IsString({ message: "tone must be a string" })
|
||||
@MinLength(1, { message: "tone must not be empty" })
|
||||
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
||||
tone!: string;
|
||||
|
||||
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
||||
formalityLevel!: FormalityLevel;
|
||||
|
||||
@IsString({ message: "systemPromptTemplate must be a string" })
|
||||
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
||||
systemPromptTemplate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(2)
|
||||
temperature?: number; // null = use provider default
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxTokens?: number; // null = use provider default
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4")
|
||||
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./create-personality.dto";
|
||||
export * from "./update-personality.dto";
|
||||
export * from "./personality-query.dto";
|
||||
|
||||
12
apps/api/src/personalities/dto/personality-query.dto.ts
Normal file
12
apps/api/src/personalities/dto/personality-query.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsBoolean, IsOptional } from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
|
||||
/**
|
||||
* DTO for querying/filtering personalities
|
||||
*/
|
||||
export class PersonalityQueryDto {
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
@Transform(({ value }) => value === "true" || value === true)
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -1,62 +1,42 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from "class-validator";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for updating an existing personality/assistant configuration
|
||||
* DTO for updating an existing personality
|
||||
* All fields are optional; only provided fields are updated.
|
||||
*/
|
||||
export class UpdatePersonalityDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name?: string; // unique identifier slug
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
displayName?: string; // human-readable name
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
systemPrompt?: string;
|
||||
@IsString({ message: "tone must be a string" })
|
||||
@MinLength(1, { message: "tone must not be empty" })
|
||||
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
||||
tone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(2)
|
||||
temperature?: number; // null = use provider default
|
||||
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
||||
formalityLevel?: FormalityLevel;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxTokens?: number; // null = use provider default
|
||||
@IsString({ message: "systemPromptTemplate must be a string" })
|
||||
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
||||
systemPromptTemplate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4")
|
||||
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import type { Personality as PrismaPersonality } from "@prisma/client";
|
||||
import type { FormalityLevel } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Personality entity representing an assistant configuration
|
||||
* Personality response entity
|
||||
* Maps Prisma Personality fields to the frontend API contract.
|
||||
*
|
||||
* Field mapping (Prisma -> API):
|
||||
* systemPrompt -> systemPromptTemplate
|
||||
* isEnabled -> isActive
|
||||
* (tone, formalityLevel are identical in both)
|
||||
*/
|
||||
export class Personality implements PrismaPersonality {
|
||||
id!: string;
|
||||
workspaceId!: string;
|
||||
name!: string; // unique identifier slug
|
||||
displayName!: string; // human-readable name
|
||||
description!: string | null;
|
||||
systemPrompt!: string;
|
||||
temperature!: number | null; // null = use provider default
|
||||
maxTokens!: number | null; // null = use provider default
|
||||
llmProviderInstanceId!: string | null; // FK to LlmProviderInstance
|
||||
isDefault!: boolean;
|
||||
isEnabled!: boolean;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
export interface PersonalityResponse {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tone: string;
|
||||
formalityLevel: FormalityLevel;
|
||||
systemPromptTemplate: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -2,36 +2,32 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PersonalitiesController } from "./personalities.controller";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
|
||||
describe("PersonalitiesController", () => {
|
||||
let controller: PersonalitiesController;
|
||||
let service: PersonalitiesService;
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockUserId = "user-123";
|
||||
const mockPersonalityId = "personality-123";
|
||||
|
||||
/** API response shape (frontend field names) */
|
||||
const mockPersonality = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
displayName: "Professional Assistant",
|
||||
description: "A professional communication assistant",
|
||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
llmProviderInstanceId: "provider-123",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
user: { id: mockUserId },
|
||||
workspaceId: mockWorkspaceId,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
const mockPersonalitiesService = {
|
||||
@@ -57,46 +53,43 @@ describe("PersonalitiesController", () => {
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue({
|
||||
canActivate: (ctx: {
|
||||
switchToHttp: () => { getRequest: () => { workspaceId: string } };
|
||||
}) => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
req.workspaceId = mockWorkspaceId;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all personalities", async () => {
|
||||
const mockPersonalities = [mockPersonality];
|
||||
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
|
||||
it("should return success response with personalities list", async () => {
|
||||
const mockList = [mockPersonality];
|
||||
mockPersonalitiesService.findAll.mockResolvedValue(mockList);
|
||||
|
||||
const result = await controller.findAll(mockRequest);
|
||||
const result = await controller.findAll(mockWorkspaceId, {});
|
||||
|
||||
expect(result).toEqual(mockPersonalities);
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
expect(result).toEqual({ success: true, data: mockList });
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||
it("should pass isActive query filter to service", async () => {
|
||||
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
|
||||
|
||||
const result = await controller.findOne(mockRequest, mockPersonalityId);
|
||||
await controller.findAll(mockWorkspaceId, { isActive: true });
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByName", () => {
|
||||
it("should return a personality by name", async () => {
|
||||
mockPersonalitiesService.findByName.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findByName(mockRequest, "professional-assistant");
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findByName).toHaveBeenCalledWith(mockWorkspaceId, "professional-assistant");
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,32 +97,40 @@ describe("PersonalitiesController", () => {
|
||||
it("should return the default personality", async () => {
|
||||
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findDefault(mockRequest);
|
||||
const result = await controller.findDefault(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findOne(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a new personality", async () => {
|
||||
const createDto: CreatePersonalityDto = {
|
||||
name: "casual-helper",
|
||||
displayName: "Casual Helper",
|
||||
description: "A casual helper",
|
||||
systemPrompt: "You are a casual assistant.",
|
||||
temperature: 0.8,
|
||||
maxTokens: 1500,
|
||||
tone: "casual",
|
||||
formalityLevel: FormalityLevel.CASUAL,
|
||||
systemPromptTemplate: "You are a casual assistant.",
|
||||
};
|
||||
|
||||
mockPersonalitiesService.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDto,
|
||||
});
|
||||
const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false };
|
||||
mockPersonalitiesService.create.mockResolvedValue(created);
|
||||
|
||||
const result = await controller.create(mockRequest, createDto);
|
||||
const result = await controller.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toMatchObject(createDto);
|
||||
expect(result).toMatchObject({ name: createDto.name, tone: createDto.tone });
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||
});
|
||||
});
|
||||
@@ -138,15 +139,13 @@ describe("PersonalitiesController", () => {
|
||||
it("should update a personality", async () => {
|
||||
const updateDto: UpdatePersonalityDto = {
|
||||
description: "Updated description",
|
||||
temperature: 0.9,
|
||||
tone: "enthusiastic",
|
||||
};
|
||||
|
||||
mockPersonalitiesService.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...updateDto,
|
||||
});
|
||||
const updated = { ...mockPersonality, ...updateDto };
|
||||
mockPersonalitiesService.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await controller.update(mockRequest, mockPersonalityId, updateDto);
|
||||
const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
|
||||
expect(result).toMatchObject(updateDto);
|
||||
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
@@ -157,7 +156,7 @@ describe("PersonalitiesController", () => {
|
||||
it("should delete a personality", async () => {
|
||||
mockPersonalitiesService.delete.mockResolvedValue(undefined);
|
||||
|
||||
await controller.delete(mockRequest, mockPersonalityId);
|
||||
await controller.delete(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
@@ -165,12 +164,10 @@ describe("PersonalitiesController", () => {
|
||||
|
||||
describe("setDefault", () => {
|
||||
it("should set a personality as default", async () => {
|
||||
mockPersonalitiesService.setDefault.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
isDefault: true,
|
||||
});
|
||||
const updated = { ...mockPersonality, isDefault: true };
|
||||
mockPersonalitiesService.setDefault.mockResolvedValue(updated);
|
||||
|
||||
const result = await controller.setDefault(mockRequest, mockPersonalityId);
|
||||
const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toMatchObject({ isDefault: true });
|
||||
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
@@ -6,105 +6,122 @@ import {
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { Personality } from "./entities/personality.entity";
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
workspaceId: string;
|
||||
}
|
||||
import { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { PersonalityQueryDto } from "./dto/personality-query.dto";
|
||||
import type { PersonalityResponse } from "./entities/personality.entity";
|
||||
|
||||
/**
|
||||
* Controller for managing personality/assistant configurations
|
||||
* Controller for personality CRUD endpoints.
|
||||
* Route: /api/personalities
|
||||
*
|
||||
* Guards applied in order:
|
||||
* 1. AuthGuard - verifies the user is authenticated
|
||||
* 2. WorkspaceGuard - validates workspace access
|
||||
* 3. PermissionGuard - checks role-based permissions
|
||||
*/
|
||||
@Controller("personality")
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller("personalities")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class PersonalitiesController {
|
||||
constructor(private readonly personalitiesService: PersonalitiesService) {}
|
||||
|
||||
/**
|
||||
* List all personalities for the workspace
|
||||
* GET /api/personalities
|
||||
* List all personalities for the workspace.
|
||||
* Supports ?isActive=true|false filter.
|
||||
*/
|
||||
@Get()
|
||||
async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> {
|
||||
return this.personalitiesService.findAll(req.workspaceId);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(
|
||||
@Workspace() workspaceId: string,
|
||||
@Query() query: PersonalityQueryDto
|
||||
): Promise<{ success: true; data: PersonalityResponse[] }> {
|
||||
const data = await this.personalitiesService.findAll(workspaceId, query);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default personality for the workspace
|
||||
* GET /api/personalities/default
|
||||
* Get the default personality for the workspace.
|
||||
* Must be declared before :id to avoid route conflicts.
|
||||
*/
|
||||
@Get("default")
|
||||
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
|
||||
return this.personalitiesService.findDefault(req.workspaceId);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findDefault(@Workspace() workspaceId: string): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.findDefault(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a personality by its unique name
|
||||
*/
|
||||
@Get("by-name/:name")
|
||||
async findByName(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("name") name: string
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.findByName(req.workspaceId, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a personality by ID
|
||||
* GET /api/personalities/:id
|
||||
* Get a single personality by ID.
|
||||
*/
|
||||
@Get(":id")
|
||||
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
|
||||
return this.personalitiesService.findOne(req.workspaceId, id);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findOne(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.findOne(workspaceId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personality
|
||||
* POST /api/personalities
|
||||
* Create a new personality.
|
||||
*/
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Body() dto: CreatePersonalityDto
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.create(req.workspaceId, dto);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.create(workspaceId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a personality
|
||||
* PATCH /api/personalities/:id
|
||||
* Update an existing personality.
|
||||
*/
|
||||
@Patch(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdatePersonalityDto
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.update(req.workspaceId, id, dto);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.update(workspaceId, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
* DELETE /api/personalities/:id
|
||||
* Delete a personality.
|
||||
*/
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
|
||||
return this.personalitiesService.delete(req.workspaceId, id);
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise<void> {
|
||||
return this.personalitiesService.delete(workspaceId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a personality as the default
|
||||
* POST /api/personalities/:id/set-default
|
||||
* Convenience endpoint to set a personality as the default.
|
||||
*/
|
||||
@Post(":id/set-default")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async setDefault(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.setDefault(req.workspaceId, id);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.setDefault(workspaceId, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { NotFoundException, ConflictException } from "@nestjs/common";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
|
||||
describe("PersonalitiesService", () => {
|
||||
let service: PersonalitiesService;
|
||||
@@ -11,22 +13,39 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockPersonalityId = "personality-123";
|
||||
const mockProviderId = "provider-123";
|
||||
|
||||
const mockPersonality = {
|
||||
/** Raw Prisma record shape (uses Prisma field names) */
|
||||
const mockPrismaRecord = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
displayName: "Professional Assistant",
|
||||
description: "A professional communication assistant",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
llmProviderInstanceId: mockProviderId,
|
||||
llmProviderInstanceId: "provider-123",
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
/** Expected API response shape (uses frontend field names) */
|
||||
const mockResponse = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
description: "A professional communication assistant",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
@@ -37,9 +56,7 @@ describe("PersonalitiesService", () => {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((callback) => callback(mockPrismaService)),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -56,44 +73,54 @@ describe("PersonalitiesService", () => {
|
||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
const createDto: CreatePersonalityDto = {
|
||||
name: "casual-helper",
|
||||
displayName: "Casual Helper",
|
||||
description: "A casual communication helper",
|
||||
systemPrompt: "You are a casual assistant.",
|
||||
temperature: 0.8,
|
||||
maxTokens: 1500,
|
||||
llmProviderInstanceId: mockProviderId,
|
||||
tone: "casual",
|
||||
formalityLevel: FormalityLevel.CASUAL,
|
||||
systemPromptTemplate: "You are a casual assistant.",
|
||||
isDefault: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
it("should create a new personality", async () => {
|
||||
const createdRecord = {
|
||||
...mockPrismaRecord,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
tone: createDto.tone,
|
||||
formalityLevel: createDto.formalityLevel,
|
||||
systemPrompt: createDto.systemPromptTemplate,
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
id: "new-personality-id",
|
||||
};
|
||||
|
||||
it("should create a new personality and return API response shape", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.personality.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDto,
|
||||
id: "new-personality-id",
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
});
|
||||
mockPrismaService.personality.create.mockResolvedValue(createdRecord);
|
||||
|
||||
const result = await service.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toMatchObject(createDto);
|
||||
expect(result.name).toBe(createDto.name);
|
||||
expect(result.tone).toBe(createDto.tone);
|
||||
expect(result.formalityLevel).toBe(createDto.formalityLevel);
|
||||
expect(result.systemPromptTemplate).toBe(createDto.systemPromptTemplate);
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(result.isDefault).toBe(false);
|
||||
|
||||
expect(prisma.personality.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
displayName: createDto.displayName,
|
||||
displayName: createDto.name,
|
||||
description: createDto.description ?? null,
|
||||
systemPrompt: createDto.systemPrompt,
|
||||
temperature: createDto.temperature ?? null,
|
||||
maxTokens: createDto.maxTokens ?? null,
|
||||
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
|
||||
tone: createDto.tone,
|
||||
formalityLevel: createDto.formalityLevel,
|
||||
systemPrompt: createDto.systemPromptTemplate,
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
@@ -101,68 +128,73 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should throw ConflictException when name already exists", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("should unset other defaults when creating a new default personality", async () => {
|
||||
const createDefaultDto = { ...createDto, isDefault: true };
|
||||
// First call to findFirst checks for name conflict (should be null)
|
||||
// Second call to findFirst finds the existing default personality
|
||||
const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true };
|
||||
const otherDefault = { ...mockPrismaRecord, id: "other-id" };
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(null) // No name conflict
|
||||
.mockResolvedValueOnce(mockPersonality); // Existing default
|
||||
mockPrismaService.personality.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
isDefault: false,
|
||||
});
|
||||
.mockResolvedValueOnce(null) // name conflict check
|
||||
.mockResolvedValueOnce(otherDefault); // existing default lookup
|
||||
mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false });
|
||||
mockPrismaService.personality.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDefaultDto,
|
||||
...createdRecord,
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
await service.create(mockWorkspaceId, createDefaultDto);
|
||||
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all personalities for a workspace", async () => {
|
||||
const mockPersonalities = [mockPersonality];
|
||||
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
|
||||
it("should return mapped response list for a workspace", async () => {
|
||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
||||
|
||||
const result = await service.findAll(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonalities);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(mockResponse);
|
||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by isActive when provided", async () => {
|
||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
||||
|
||||
await service.findAll(mockWorkspaceId, { isActive: true });
|
||||
|
||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isEnabled: true },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
it("should return a mapped personality response by id", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId, workspaceId: mockWorkspaceId },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
@@ -171,17 +203,14 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
describe("findByName", () => {
|
||||
it("should return a personality by name", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
it("should return a mapped personality response by name", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
},
|
||||
where: { workspaceId: mockWorkspaceId, name: "professional-assistant" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,11 +225,11 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
describe("findDefault", () => {
|
||||
it("should return the default personality", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findDefault(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
|
||||
});
|
||||
@@ -216,41 +245,45 @@ describe("PersonalitiesService", () => {
|
||||
describe("update", () => {
|
||||
const updateDto: UpdatePersonalityDto = {
|
||||
description: "Updated description",
|
||||
temperature: 0.9,
|
||||
tone: "formal",
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
it("should update a personality", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.personality.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...updateDto,
|
||||
});
|
||||
it("should update a personality and return mapped response", async () => {
|
||||
const updatedRecord = {
|
||||
...mockPrismaRecord,
|
||||
description: updateDto.description,
|
||||
tone: updateDto.tone,
|
||||
isEnabled: false,
|
||||
};
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(null); // name conflict check (no dto.name here)
|
||||
mockPrismaService.personality.update.mockResolvedValue(updatedRecord);
|
||||
|
||||
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
|
||||
expect(result).toMatchObject(updateDto);
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
data: updateDto,
|
||||
});
|
||||
expect(result.description).toBe(updateDto.description);
|
||||
expect(result.tone).toBe(updateDto.tone);
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ConflictException when updating to existing name", async () => {
|
||||
const updateNameDto = { name: "existing-name" };
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
id: "different-id",
|
||||
});
|
||||
it("should throw ConflictException when updating to an existing name", async () => {
|
||||
const updateNameDto: UpdatePersonalityDto = { name: "existing-name" };
|
||||
const conflictRecord = { ...mockPrismaRecord, id: "different-id" };
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(conflictRecord); // name conflict
|
||||
|
||||
await expect(
|
||||
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
|
||||
@@ -258,14 +291,16 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should unset other defaults when setting as default", async () => {
|
||||
const updateDefaultDto = { isDefault: true };
|
||||
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
||||
const updateDefaultDto: UpdatePersonalityDto = { isDefault: true };
|
||||
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
||||
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
||||
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
||||
mockPrismaService.personality.update
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
||||
.mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
||||
.mockResolvedValueOnce(updatedRecord);
|
||||
|
||||
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
|
||||
|
||||
@@ -273,16 +308,12 @@ describe("PersonalitiesService", () => {
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
||||
where: { id: mockPersonalityId },
|
||||
data: updateDefaultDto,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete a personality", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
mockPrismaService.personality.delete.mockResolvedValue(undefined);
|
||||
|
||||
await service.delete(mockWorkspaceId, mockPersonalityId);
|
||||
@@ -293,7 +324,7 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
@@ -303,30 +334,27 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
describe("setDefault", () => {
|
||||
it("should set a personality as default", async () => {
|
||||
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
||||
const updatedPersonality = { ...mockPersonality, isDefault: true };
|
||||
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
||||
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
||||
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
||||
mockPrismaService.personality.update
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
||||
.mockResolvedValueOnce(updatedPersonality); // Set new default
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
||||
.mockResolvedValueOnce(updatedRecord);
|
||||
|
||||
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toMatchObject({ isDefault: true });
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
||||
expect(result.isDefault).toBe(true);
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
|
||||
import type { FormalityLevel, Personality } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { Personality } from "./entities/personality.entity";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import type { PersonalityQueryDto } from "./dto/personality-query.dto";
|
||||
import type { PersonalityResponse } from "./entities/personality.entity";
|
||||
|
||||
/**
|
||||
* Service for managing personality/assistant configurations
|
||||
* Service for managing personality/assistant configurations.
|
||||
*
|
||||
* Field mapping:
|
||||
* Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate`
|
||||
* Prisma `isEnabled` <-> API/frontend `isActive`
|
||||
*/
|
||||
@Injectable()
|
||||
export class PersonalitiesService {
|
||||
@@ -12,11 +19,30 @@ export class PersonalitiesService {
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Map a Prisma Personality record to the API response shape.
|
||||
*/
|
||||
private toResponse(personality: Personality): PersonalityResponse {
|
||||
return {
|
||||
id: personality.id,
|
||||
workspaceId: personality.workspaceId,
|
||||
name: personality.name,
|
||||
description: personality.description,
|
||||
tone: personality.tone,
|
||||
formalityLevel: personality.formalityLevel,
|
||||
systemPromptTemplate: personality.systemPrompt,
|
||||
isDefault: personality.isDefault,
|
||||
isActive: personality.isEnabled,
|
||||
createdAt: personality.createdAt,
|
||||
updatedAt: personality.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personality
|
||||
*/
|
||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
|
||||
// Check for duplicate name
|
||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<PersonalityResponse> {
|
||||
// Check for duplicate name within workspace
|
||||
const existing = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, name: dto.name },
|
||||
});
|
||||
@@ -25,7 +51,7 @@ export class PersonalitiesService {
|
||||
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
|
||||
}
|
||||
|
||||
// If creating a default personality, unset other defaults
|
||||
// If creating as default, unset other defaults first
|
||||
if (dto.isDefault) {
|
||||
await this.unsetOtherDefaults(workspaceId);
|
||||
}
|
||||
@@ -34,36 +60,43 @@ export class PersonalitiesService {
|
||||
data: {
|
||||
workspaceId,
|
||||
name: dto.name,
|
||||
displayName: dto.displayName,
|
||||
displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
|
||||
description: dto.description ?? null,
|
||||
systemPrompt: dto.systemPrompt,
|
||||
temperature: dto.temperature ?? null,
|
||||
maxTokens: dto.maxTokens ?? null,
|
||||
llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
|
||||
tone: dto.tone,
|
||||
formalityLevel: dto.formalityLevel,
|
||||
systemPrompt: dto.systemPromptTemplate,
|
||||
isDefault: dto.isDefault ?? false,
|
||||
isEnabled: dto.isEnabled ?? true,
|
||||
isEnabled: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all personalities for a workspace
|
||||
* Find all personalities for a workspace with optional active filter
|
||||
*/
|
||||
async findAll(workspaceId: string): Promise<Personality[]> {
|
||||
return this.prisma.personality.findMany({
|
||||
where: { workspaceId },
|
||||
async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise<PersonalityResponse[]> {
|
||||
const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId };
|
||||
|
||||
if (query?.isActive !== undefined) {
|
||||
where.isEnabled = query.isActive;
|
||||
}
|
||||
|
||||
const personalities = await this.prisma.personality.findMany({
|
||||
where,
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
return personalities.map((p) => this.toResponse(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific personality by ID
|
||||
*/
|
||||
async findOne(workspaceId: string, id: string): Promise<Personality> {
|
||||
const personality = await this.prisma.personality.findUnique({
|
||||
async findOne(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
@@ -71,13 +104,13 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`Personality with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a personality by name
|
||||
* Find a personality by name slug
|
||||
*/
|
||||
async findByName(workspaceId: string, name: string): Promise<Personality> {
|
||||
async findByName(workspaceId: string, name: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, name },
|
||||
});
|
||||
@@ -86,13 +119,13 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`Personality with name "${name}" not found`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the default personality for a workspace
|
||||
* Find the default (and enabled) personality for a workspace
|
||||
*/
|
||||
async findDefault(workspaceId: string): Promise<Personality> {
|
||||
async findDefault(workspaceId: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, isDefault: true, isEnabled: true },
|
||||
});
|
||||
@@ -101,14 +134,18 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing personality
|
||||
*/
|
||||
async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise<Personality> {
|
||||
// Check existence
|
||||
async update(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
dto: UpdatePersonalityDto
|
||||
): Promise<PersonalityResponse> {
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
// Check for duplicate name if updating name
|
||||
@@ -127,20 +164,43 @@ export class PersonalitiesService {
|
||||
await this.unsetOtherDefaults(workspaceId, id);
|
||||
}
|
||||
|
||||
// Build update data with field mapping
|
||||
const updateData: {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
tone?: string;
|
||||
formalityLevel?: FormalityLevel;
|
||||
systemPrompt?: string;
|
||||
isDefault?: boolean;
|
||||
isEnabled?: boolean;
|
||||
} = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
updateData.name = dto.name;
|
||||
updateData.displayName = dto.name;
|
||||
}
|
||||
if (dto.description !== undefined) updateData.description = dto.description;
|
||||
if (dto.tone !== undefined) updateData.tone = dto.tone;
|
||||
if (dto.formalityLevel !== undefined) updateData.formalityLevel = dto.formalityLevel;
|
||||
if (dto.systemPromptTemplate !== undefined) updateData.systemPrompt = dto.systemPromptTemplate;
|
||||
if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault;
|
||||
if (dto.isActive !== undefined) updateData.isEnabled = dto.isActive;
|
||||
|
||||
const personality = await this.prisma.personality.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
*/
|
||||
async delete(workspaceId: string, id: string): Promise<void> {
|
||||
// Check existence
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
await this.prisma.personality.delete({
|
||||
@@ -151,23 +211,22 @@ export class PersonalitiesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a personality as the default
|
||||
* Set a personality as the default (convenience endpoint)
|
||||
*/
|
||||
async setDefault(workspaceId: string, id: string): Promise<Personality> {
|
||||
// Check existence
|
||||
async setDefault(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
// Unset other defaults
|
||||
await this.unsetOtherDefaults(workspaceId, id);
|
||||
|
||||
// Set this one as default
|
||||
const personality = await this.prisma.personality.update({
|
||||
where: { id },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
|
||||
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +237,7 @@ export class PersonalitiesService {
|
||||
where: {
|
||||
workspaceId,
|
||||
isDefault: true,
|
||||
...(excludeId && { id: { not: excludeId } }),
|
||||
...(excludeId !== undefined && { id: { not: excludeId } }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -140,8 +140,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
workspaceId: string,
|
||||
client: PrismaClient = this
|
||||
): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
||||
// Use set_config() instead of SET LOCAL so values are safely parameterized.
|
||||
// SET LOCAL with Prisma's tagged template produces invalid SQL (bind parameter $1
|
||||
// is not supported in SET statements by PostgreSQL).
|
||||
await client.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,8 +154,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
* @param client - Optional Prisma client (uses 'this' if not provided)
|
||||
*/
|
||||
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_user_id', '', true)`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_workspace_id', '', true)`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RunnerJobsService } from "./runner-jobs.service";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { BullMqModule } from "../bullmq/bullmq.module";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { WebSocketModule } from "../websocket/websocket.module";
|
||||
|
||||
/**
|
||||
* Runner Jobs Module
|
||||
@@ -12,7 +13,7 @@ import { AuthModule } from "../auth/auth.module";
|
||||
* for asynchronous job processing.
|
||||
*/
|
||||
@Module({
|
||||
imports: [PrismaModule, BullMqModule, AuthModule],
|
||||
imports: [PrismaModule, BullMqModule, AuthModule, WebSocketModule],
|
||||
controllers: [RunnerJobsController],
|
||||
providers: [RunnerJobsService],
|
||||
exports: [RunnerJobsService],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RunnerJobsService } from "./runner-jobs.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||
import { RunnerJobStatus } from "@prisma/client";
|
||||
import { ConflictException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@@ -19,6 +20,12 @@ describe("RunnerJobsService - Concurrency", () => {
|
||||
getQueue: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWebSocketGateway = {
|
||||
emitJobCreated: vi.fn(),
|
||||
emitJobStatusChanged: vi.fn(),
|
||||
emitJobProgress: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -37,6 +44,10 @@ describe("RunnerJobsService - Concurrency", () => {
|
||||
provide: BullMqService,
|
||||
useValue: mockBullMqService,
|
||||
},
|
||||
{
|
||||
provide: WebSocketGateway,
|
||||
useValue: mockWebSocketGateway,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { RunnerJobsService } from "./runner-jobs.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||
import { RunnerJobStatus } from "@prisma/client";
|
||||
import { NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
import { CreateJobDto, QueryJobsDto } from "./dto";
|
||||
@@ -32,6 +33,12 @@ describe("RunnerJobsService", () => {
|
||||
getQueue: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWebSocketGateway = {
|
||||
emitJobCreated: vi.fn(),
|
||||
emitJobStatusChanged: vi.fn(),
|
||||
emitJobProgress: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -44,6 +51,10 @@ describe("RunnerJobsService", () => {
|
||||
provide: BullMqService,
|
||||
useValue: mockBullMqService,
|
||||
},
|
||||
{
|
||||
provide: WebSocketGateway,
|
||||
useValue: mockWebSocketGateway,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Prisma, RunnerJobStatus } from "@prisma/client";
|
||||
import { Response } from "express";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { BullMqService } from "../bullmq/bullmq.service";
|
||||
import { WebSocketGateway } from "../websocket/websocket.gateway";
|
||||
import { QUEUE_NAMES } from "../bullmq/queues";
|
||||
import { ConcurrentUpdateException } from "../common/exceptions/concurrent-update.exception";
|
||||
import type { CreateJobDto, QueryJobsDto } from "./dto";
|
||||
@@ -14,7 +15,8 @@ import type { CreateJobDto, QueryJobsDto } from "./dto";
|
||||
export class RunnerJobsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly bullMq: BullMqService
|
||||
private readonly bullMq: BullMqService,
|
||||
private readonly wsGateway: WebSocketGateway
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -56,6 +58,8 @@ export class RunnerJobsService {
|
||||
{ priority }
|
||||
);
|
||||
|
||||
this.wsGateway.emitJobCreated(workspaceId, job);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
@@ -194,6 +198,13 @@ export class RunnerJobsService {
|
||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after cancel`);
|
||||
}
|
||||
|
||||
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
|
||||
id,
|
||||
workspaceId,
|
||||
status: job.status,
|
||||
previousStatus: existingJob.status,
|
||||
});
|
||||
|
||||
return job;
|
||||
});
|
||||
}
|
||||
@@ -248,6 +259,8 @@ export class RunnerJobsService {
|
||||
{ priority: existingJob.priority }
|
||||
);
|
||||
|
||||
this.wsGateway.emitJobCreated(workspaceId, newJob);
|
||||
|
||||
return newJob;
|
||||
}
|
||||
|
||||
@@ -530,6 +543,13 @@ export class RunnerJobsService {
|
||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||
}
|
||||
|
||||
this.wsGateway.emitJobStatusChanged(workspaceId, id, {
|
||||
id,
|
||||
workspaceId,
|
||||
status: updatedJob.status,
|
||||
previousStatus: existingJob.status,
|
||||
});
|
||||
|
||||
return updatedJob;
|
||||
});
|
||||
}
|
||||
@@ -606,6 +626,12 @@ export class RunnerJobsService {
|
||||
throw new NotFoundException(`RunnerJob with ID ${id} not found after update`);
|
||||
}
|
||||
|
||||
this.wsGateway.emitJobProgress(workspaceId, id, {
|
||||
id,
|
||||
workspaceId,
|
||||
progressPercent: updatedJob.progressPercent,
|
||||
});
|
||||
|
||||
return updatedJob;
|
||||
});
|
||||
}
|
||||
|
||||
13
apps/api/src/teams/dto/create-team.dto.ts
Normal file
13
apps/api/src/teams/dto/create-team.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from "class-validator";
|
||||
|
||||
export class CreateTeamDto {
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
|
||||
description?: string;
|
||||
}
|
||||
11
apps/api/src/teams/dto/manage-team-member.dto.ts
Normal file
11
apps/api/src/teams/dto/manage-team-member.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { TeamMemberRole } from "@prisma/client";
|
||||
import { IsEnum, IsOptional, IsUUID } from "class-validator";
|
||||
|
||||
export class ManageTeamMemberDto {
|
||||
@IsUUID("4", { message: "userId must be a valid UUID" })
|
||||
userId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(TeamMemberRole, { message: "role must be a valid TeamMemberRole" })
|
||||
role?: TeamMemberRole;
|
||||
}
|
||||
150
apps/api/src/teams/teams.controller.spec.ts
Normal file
150
apps/api/src/teams/teams.controller.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { TeamMemberRole } from "@prisma/client";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { PermissionGuard, WorkspaceGuard } from "../common/guards";
|
||||
import { TeamsController } from "./teams.controller";
|
||||
import { TeamsService } from "./teams.service";
|
||||
|
||||
describe("TeamsController", () => {
|
||||
let controller: TeamsController;
|
||||
let service: TeamsService;
|
||||
|
||||
const mockTeamsService = {
|
||||
create: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
addMember: vi.fn(),
|
||||
removeMember: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockTeamId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [TeamsController],
|
||||
providers: [
|
||||
{
|
||||
provide: TeamsService,
|
||||
useValue: mockTeamsService,
|
||||
},
|
||||
],
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue({ canActivate: vi.fn(() => true) })
|
||||
.compile();
|
||||
|
||||
controller = module.get<TeamsController>(TeamsController);
|
||||
service = module.get<TeamsService>(TeamsService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a team in a workspace", async () => {
|
||||
const createDto = {
|
||||
name: "Platform Team",
|
||||
description: "Owns platform services",
|
||||
};
|
||||
|
||||
const createdTeam = {
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockTeamsService.create.mockResolvedValue(createdTeam);
|
||||
|
||||
const result = await controller.create(createDto, mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(createdTeam);
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should list teams in a workspace", async () => {
|
||||
const teams = [
|
||||
{
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "Platform Team",
|
||||
description: "Owns platform services",
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_count: { members: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
mockTeamsService.findAll.mockResolvedValue(teams);
|
||||
|
||||
const result = await controller.findAll(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(teams);
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMember", () => {
|
||||
it("should add a member to a team", async () => {
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const createdTeamMember = {
|
||||
teamId: mockTeamId,
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
joinedAt: new Date(),
|
||||
user: {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
mockTeamsService.addMember.mockResolvedValue(createdTeamMember);
|
||||
|
||||
const result = await controller.addMember(mockTeamId, dto, mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(createdTeamMember);
|
||||
expect(service.addMember).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId, dto);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeMember", () => {
|
||||
it("should remove a member from a team", async () => {
|
||||
mockTeamsService.removeMember.mockResolvedValue(undefined);
|
||||
|
||||
await controller.removeMember(mockTeamId, mockUserId, mockWorkspaceId);
|
||||
|
||||
expect(service.removeMember).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId, mockUserId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should delete a team", async () => {
|
||||
mockTeamsService.remove.mockResolvedValue(undefined);
|
||||
|
||||
await controller.remove(mockTeamId, mockWorkspaceId);
|
||||
|
||||
expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId);
|
||||
});
|
||||
});
|
||||
});
|
||||
51
apps/api/src/teams/teams.controller.ts
Normal file
51
apps/api/src/teams/teams.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { PermissionGuard, WorkspaceGuard } from "../common/guards";
|
||||
import { Permission, RequirePermission, Workspace } from "../common/decorators";
|
||||
import { CreateTeamDto } from "./dto/create-team.dto";
|
||||
import { ManageTeamMemberDto } from "./dto/manage-team-member.dto";
|
||||
import { TeamsService } from "./teams.service";
|
||||
|
||||
@Controller("workspaces/:workspaceId/teams")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class TeamsController {
|
||||
constructor(private readonly teamsService: TeamsService) {}
|
||||
|
||||
@Post()
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async create(@Body() createTeamDto: CreateTeamDto, @Workspace() workspaceId: string) {
|
||||
return this.teamsService.create(workspaceId, createTeamDto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(@Workspace() workspaceId: string) {
|
||||
return this.teamsService.findAll(workspaceId);
|
||||
}
|
||||
|
||||
@Post(":teamId/members")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async addMember(
|
||||
@Param("teamId") teamId: string,
|
||||
@Body() dto: ManageTeamMemberDto,
|
||||
@Workspace() workspaceId: string
|
||||
) {
|
||||
return this.teamsService.addMember(workspaceId, teamId, dto);
|
||||
}
|
||||
|
||||
@Delete(":teamId/members/:userId")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async removeMember(
|
||||
@Param("teamId") teamId: string,
|
||||
@Param("userId") userId: string,
|
||||
@Workspace() workspaceId: string
|
||||
) {
|
||||
return this.teamsService.removeMember(workspaceId, teamId, userId);
|
||||
}
|
||||
|
||||
@Delete(":teamId")
|
||||
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||
async remove(@Param("teamId") teamId: string, @Workspace() workspaceId: string) {
|
||||
return this.teamsService.remove(workspaceId, teamId);
|
||||
}
|
||||
}
|
||||
13
apps/api/src/teams/teams.module.ts
Normal file
13
apps/api/src/teams/teams.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
import { TeamsController } from "./teams.controller";
|
||||
import { TeamsService } from "./teams.service";
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, AuthModule],
|
||||
controllers: [TeamsController],
|
||||
providers: [TeamsService],
|
||||
exports: [TeamsService],
|
||||
})
|
||||
export class TeamsModule {}
|
||||
286
apps/api/src/teams/teams.service.spec.ts
Normal file
286
apps/api/src/teams/teams.service.spec.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { TeamMemberRole } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { TeamsService } from "./teams.service";
|
||||
|
||||
describe("TeamsService", () => {
|
||||
let service: TeamsService;
|
||||
let prisma: PrismaService;
|
||||
|
||||
const mockPrismaService = {
|
||||
team: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
workspaceMember: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
teamMember: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||
const mockTeamId = "550e8400-e29b-41d4-a716-446655440002";
|
||||
const mockUserId = "550e8400-e29b-41d4-a716-446655440003";
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
TeamsService,
|
||||
{
|
||||
provide: PrismaService,
|
||||
useValue: mockPrismaService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<TeamsService>(TeamsService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should be defined", () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a team", async () => {
|
||||
const createDto = {
|
||||
name: "Platform Team",
|
||||
description: "Owns platform services",
|
||||
};
|
||||
|
||||
const createdTeam = {
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
mockPrismaService.team.create.mockResolvedValue(createdTeam);
|
||||
|
||||
const result = await service.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toEqual(createdTeam);
|
||||
expect(prisma.team.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should list teams for a workspace", async () => {
|
||||
const teams = [
|
||||
{
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "Platform Team",
|
||||
description: "Owns platform services",
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
_count: { members: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
mockPrismaService.team.findMany.mockResolvedValue(teams);
|
||||
|
||||
const result = await service.findAll(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(teams);
|
||||
expect(prisma.team.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { members: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMember", () => {
|
||||
it("should add a workspace member to a team", async () => {
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
};
|
||||
|
||||
const createdTeamMember = {
|
||||
teamId: mockTeamId,
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
joinedAt: new Date(),
|
||||
user: {
|
||||
id: mockUserId,
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
};
|
||||
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||
mockPrismaService.teamMember.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.teamMember.create.mockResolvedValue(createdTeamMember);
|
||||
|
||||
const result = await service.addMember(mockWorkspaceId, mockTeamId, dto);
|
||||
|
||||
expect(result).toEqual(createdTeamMember);
|
||||
expect(prisma.team.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
expect(prisma.workspaceMember.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
userId: mockUserId,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
expect(prisma.teamMember.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
teamId: mockTeamId,
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should use MEMBER role when role is omitted", async () => {
|
||||
const dto = { userId: mockUserId };
|
||||
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||
mockPrismaService.teamMember.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.teamMember.create.mockResolvedValue({
|
||||
teamId: mockTeamId,
|
||||
userId: mockUserId,
|
||||
role: TeamMemberRole.MEMBER,
|
||||
joinedAt: new Date(),
|
||||
});
|
||||
|
||||
await service.addMember(mockWorkspaceId, mockTeamId, dto);
|
||||
|
||||
expect(prisma.teamMember.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
role: TeamMemberRole.MEMBER,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when team does not belong to workspace", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
|
||||
).rejects.toThrow(NotFoundException);
|
||||
expect(prisma.workspaceMember.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw when user is not a workspace member", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("should throw when user is already in the team", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||
mockPrismaService.teamMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||
|
||||
await expect(
|
||||
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
|
||||
).rejects.toThrow(ConflictException);
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeMember", () => {
|
||||
it("should remove a member from a team", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.teamMember.deleteMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
await service.removeMember(mockWorkspaceId, mockTeamId, mockUserId);
|
||||
|
||||
expect(prisma.teamMember.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
teamId: mockTeamId,
|
||||
userId: mockUserId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw when team does not belong to workspace", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.removeMember(mockWorkspaceId, mockTeamId, mockUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
expect(prisma.teamMember.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw when user is not in the team", async () => {
|
||||
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||
mockPrismaService.teamMember.deleteMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
await expect(service.removeMember(mockWorkspaceId, mockTeamId, mockUserId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("remove", () => {
|
||||
it("should delete a team", async () => {
|
||||
mockPrismaService.team.deleteMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
await service.remove(mockWorkspaceId, mockTeamId);
|
||||
|
||||
expect(prisma.team.deleteMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockTeamId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw when team is not found", async () => {
|
||||
mockPrismaService.team.deleteMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
await expect(service.remove(mockWorkspaceId, mockTeamId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
130
apps/api/src/teams/teams.service.ts
Normal file
130
apps/api/src/teams/teams.service.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { TeamMemberRole } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreateTeamDto } from "./dto/create-team.dto";
|
||||
import { ManageTeamMemberDto } from "./dto/manage-team-member.dto";
|
||||
|
||||
@Injectable()
|
||||
export class TeamsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async create(workspaceId: string, createTeamDto: CreateTeamDto) {
|
||||
return this.prisma.team.create({
|
||||
data: {
|
||||
workspaceId,
|
||||
name: createTeamDto.name,
|
||||
description: createTeamDto.description ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(workspaceId: string) {
|
||||
return this.prisma.team.findMany({
|
||||
where: { workspaceId },
|
||||
include: {
|
||||
_count: {
|
||||
select: { members: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async addMember(workspaceId: string, teamId: string, dto: ManageTeamMemberDto) {
|
||||
await this.ensureTeamInWorkspace(workspaceId, teamId);
|
||||
|
||||
const workspaceMember = await this.prisma.workspaceMember.findUnique({
|
||||
where: {
|
||||
workspaceId_userId: {
|
||||
workspaceId,
|
||||
userId: dto.userId,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!workspaceMember) {
|
||||
throw new BadRequestException(
|
||||
`User ${dto.userId} must be a workspace member before being added to a team`
|
||||
);
|
||||
}
|
||||
|
||||
const existingTeamMember = await this.prisma.teamMember.findUnique({
|
||||
where: {
|
||||
teamId_userId: {
|
||||
teamId,
|
||||
userId: dto.userId,
|
||||
},
|
||||
},
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (existingTeamMember) {
|
||||
throw new ConflictException(`User ${dto.userId} is already a member of team ${teamId}`);
|
||||
}
|
||||
|
||||
return this.prisma.teamMember.create({
|
||||
data: {
|
||||
teamId,
|
||||
userId: dto.userId,
|
||||
role: dto.role ?? TeamMemberRole.MEMBER,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async removeMember(workspaceId: string, teamId: string, userId: string): Promise<void> {
|
||||
await this.ensureTeamInWorkspace(workspaceId, teamId);
|
||||
|
||||
const result = await this.prisma.teamMember.deleteMany({
|
||||
where: {
|
||||
teamId,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
throw new NotFoundException(`User ${userId} is not a member of team ${teamId}`);
|
||||
}
|
||||
}
|
||||
|
||||
async remove(workspaceId: string, teamId: string): Promise<void> {
|
||||
const result = await this.prisma.team.deleteMany({
|
||||
where: {
|
||||
id: teamId,
|
||||
workspaceId,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.count === 0) {
|
||||
throw new NotFoundException(`Team with ID ${teamId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureTeamInWorkspace(workspaceId: string, teamId: string): Promise<void> {
|
||||
const team = await this.prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
workspaceId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
throw new NotFoundException(`Team with ID ${teamId} not found`);
|
||||
}
|
||||
}
|
||||
}
|
||||
53
apps/api/src/terminal/terminal-session.dto.ts
Normal file
53
apps/api/src/terminal/terminal-session.dto.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Terminal Session DTOs
|
||||
*
|
||||
* Data Transfer Objects for terminal session persistence endpoints.
|
||||
* Validated using class-validator decorators.
|
||||
*/
|
||||
|
||||
import { IsString, IsOptional, MaxLength, IsEnum, IsUUID } from "class-validator";
|
||||
import { TerminalSessionStatus } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* DTO for creating a new terminal session record.
|
||||
*/
|
||||
export class CreateTerminalSessionDto {
|
||||
@IsString()
|
||||
@IsUUID()
|
||||
workspaceId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for querying terminal sessions by workspace.
|
||||
*/
|
||||
export class FindTerminalSessionsByWorkspaceDto {
|
||||
@IsString()
|
||||
@IsUUID()
|
||||
workspaceId!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape for a terminal session.
|
||||
*/
|
||||
export class TerminalSessionResponseDto {
|
||||
id!: string;
|
||||
workspaceId!: string;
|
||||
name!: string;
|
||||
status!: TerminalSessionStatus;
|
||||
createdAt!: Date;
|
||||
closedAt!: Date | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for filtering terminal sessions by status.
|
||||
*/
|
||||
export class TerminalSessionStatusFilterDto {
|
||||
@IsOptional()
|
||||
@IsEnum(TerminalSessionStatus)
|
||||
status?: TerminalSessionStatus;
|
||||
}
|
||||
229
apps/api/src/terminal/terminal-session.service.spec.ts
Normal file
229
apps/api/src/terminal/terminal-session.service.spec.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* TerminalSessionService Tests
|
||||
*
|
||||
* Unit tests for database-backed terminal session CRUD:
|
||||
* create, findByWorkspace, close, and findById.
|
||||
* PrismaService is mocked to isolate the service logic.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { NotFoundException } from "@nestjs/common";
|
||||
import { TerminalSessionStatus } from "@prisma/client";
|
||||
import type { TerminalSession } from "@prisma/client";
|
||||
import { TerminalSessionService } from "./terminal-session.service";
|
||||
|
||||
// ==========================================
|
||||
// Helpers
|
||||
// ==========================================
|
||||
|
||||
function makeSession(overrides: Partial<TerminalSession> = {}): TerminalSession {
|
||||
return {
|
||||
id: "session-uuid-1",
|
||||
workspaceId: "workspace-uuid-1",
|
||||
name: "Terminal",
|
||||
status: TerminalSessionStatus.ACTIVE,
|
||||
createdAt: new Date("2026-02-25T00:00:00Z"),
|
||||
closedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Mock PrismaService
|
||||
// ==========================================
|
||||
|
||||
function makeMockPrisma() {
|
||||
return {
|
||||
terminalSession: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Tests
|
||||
// ==========================================
|
||||
|
||||
describe("TerminalSessionService", () => {
|
||||
let service: TerminalSessionService;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mockPrisma: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockPrisma = makeMockPrisma();
|
||||
service = new TerminalSessionService(mockPrisma);
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// create
|
||||
// ==========================================
|
||||
describe("create", () => {
|
||||
it("should call prisma.terminalSession.create with workspaceId only when no name provided", async () => {
|
||||
const session = makeSession();
|
||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.create("workspace-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
||||
data: { workspaceId: "workspace-uuid-1" },
|
||||
});
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("should include name in create data when name is provided", async () => {
|
||||
const session = makeSession({ name: "My Terminal" });
|
||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.create("workspace-uuid-1", "My Terminal");
|
||||
|
||||
expect(mockPrisma.terminalSession.create).toHaveBeenCalledWith({
|
||||
data: { workspaceId: "workspace-uuid-1", name: "My Terminal" },
|
||||
});
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("should return the created session", async () => {
|
||||
const session = makeSession();
|
||||
mockPrisma.terminalSession.create.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.create("workspace-uuid-1");
|
||||
|
||||
expect(result.id).toBe("session-uuid-1");
|
||||
expect(result.status).toBe(TerminalSessionStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// findByWorkspace
|
||||
// ==========================================
|
||||
describe("findByWorkspace", () => {
|
||||
it("should query for ACTIVE sessions in the given workspace, ordered by createdAt desc", async () => {
|
||||
const sessions = [makeSession(), makeSession({ id: "session-uuid-2" })];
|
||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce(sessions);
|
||||
|
||||
const result = await service.findByWorkspace("workspace-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: "workspace-uuid-1",
|
||||
status: TerminalSessionStatus.ACTIVE,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should return an empty array when no active sessions exist", async () => {
|
||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await service.findByWorkspace("workspace-uuid-empty");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not include CLOSED sessions", async () => {
|
||||
// The where clause enforces ACTIVE status — verify it is present
|
||||
mockPrisma.terminalSession.findMany.mockResolvedValueOnce([]);
|
||||
|
||||
await service.findByWorkspace("workspace-uuid-1");
|
||||
|
||||
const callArgs = mockPrisma.terminalSession.findMany.mock.calls[0][0] as {
|
||||
where: { status: TerminalSessionStatus };
|
||||
};
|
||||
expect(callArgs.where.status).toBe(TerminalSessionStatus.ACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// close
|
||||
// ==========================================
|
||||
describe("close", () => {
|
||||
it("should set status to CLOSED and set closedAt when session exists", async () => {
|
||||
const existingSession = makeSession();
|
||||
const closedSession = makeSession({
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date("2026-02-25T01:00:00Z"),
|
||||
});
|
||||
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
||||
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
||||
|
||||
const result = await service.close("session-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "session-uuid-1" },
|
||||
});
|
||||
expect(mockPrisma.terminalSession.update).toHaveBeenCalledWith({
|
||||
where: { id: "session-uuid-1" },
|
||||
data: {
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: expect.any(Date),
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(TerminalSessionStatus.CLOSED);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when session does not exist", async () => {
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(service.close("nonexistent-id")).rejects.toThrow(NotFoundException);
|
||||
expect(mockPrisma.terminalSession.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include a non-null closedAt timestamp on close", async () => {
|
||||
const existingSession = makeSession();
|
||||
const closedSession = makeSession({
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
});
|
||||
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(existingSession);
|
||||
mockPrisma.terminalSession.update.mockResolvedValueOnce(closedSession);
|
||||
|
||||
const result = await service.close("session-uuid-1");
|
||||
|
||||
expect(result.closedAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// findById
|
||||
// ==========================================
|
||||
describe("findById", () => {
|
||||
it("should return the session when it exists", async () => {
|
||||
const session = makeSession();
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(session);
|
||||
|
||||
const result = await service.findById("session-uuid-1");
|
||||
|
||||
expect(mockPrisma.terminalSession.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "session-uuid-1" },
|
||||
});
|
||||
expect(result).toEqual(session);
|
||||
});
|
||||
|
||||
it("should return null when session does not exist", async () => {
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(null);
|
||||
|
||||
const result = await service.findById("no-such-id");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should find CLOSED sessions as well as ACTIVE ones", async () => {
|
||||
const closedSession = makeSession({
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
});
|
||||
mockPrisma.terminalSession.findUnique.mockResolvedValueOnce(closedSession);
|
||||
|
||||
const result = await service.findById("session-uuid-1");
|
||||
|
||||
expect(result?.status).toBe(TerminalSessionStatus.CLOSED);
|
||||
});
|
||||
});
|
||||
});
|
||||
96
apps/api/src/terminal/terminal-session.service.ts
Normal file
96
apps/api/src/terminal/terminal-session.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* TerminalSessionService
|
||||
*
|
||||
* Manages database persistence for terminal sessions.
|
||||
* Provides CRUD operations on the TerminalSession model,
|
||||
* enabling session tracking, recovery, and workspace-level listing.
|
||||
*
|
||||
* Session lifecycle:
|
||||
* - create: record a new terminal session with ACTIVE status
|
||||
* - findByWorkspace: return all ACTIVE sessions for a workspace
|
||||
* - close: mark a session as CLOSED, set closedAt timestamp
|
||||
* - findById: retrieve a single session by ID
|
||||
*/
|
||||
|
||||
import { Injectable, NotFoundException, Logger } from "@nestjs/common";
|
||||
import { TerminalSessionStatus } from "@prisma/client";
|
||||
import type { TerminalSession } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
@Injectable()
|
||||
export class TerminalSessionService {
|
||||
private readonly logger = new Logger(TerminalSessionService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Create a new terminal session record in the database.
|
||||
*
|
||||
* @param workspaceId - The workspace this session belongs to
|
||||
* @param name - Optional display name for the session (defaults to "Terminal")
|
||||
* @returns The created TerminalSession record
|
||||
*/
|
||||
async create(workspaceId: string, name?: string): Promise<TerminalSession> {
|
||||
this.logger.log(
|
||||
`Creating terminal session for workspace ${workspaceId}${name !== undefined ? ` (name: ${name})` : ""}`
|
||||
);
|
||||
|
||||
const data: { workspaceId: string; name?: string } = { workspaceId };
|
||||
if (name !== undefined) {
|
||||
data.name = name;
|
||||
}
|
||||
|
||||
return this.prisma.terminalSession.create({ data });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all ACTIVE terminal sessions for a workspace.
|
||||
*
|
||||
* @param workspaceId - The workspace to query
|
||||
* @returns Array of active TerminalSession records, ordered by creation time (newest first)
|
||||
*/
|
||||
async findByWorkspace(workspaceId: string): Promise<TerminalSession[]> {
|
||||
return this.prisma.terminalSession.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
status: TerminalSessionStatus.ACTIVE,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a terminal session by setting its status to CLOSED and recording closedAt.
|
||||
*
|
||||
* @param id - The session ID to close
|
||||
* @returns The updated TerminalSession record
|
||||
* @throws NotFoundException if the session does not exist
|
||||
*/
|
||||
async close(id: string): Promise<TerminalSession> {
|
||||
const existing = await this.prisma.terminalSession.findUnique({ where: { id } });
|
||||
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`Terminal session ${id} not found`);
|
||||
}
|
||||
|
||||
this.logger.log(`Closing terminal session ${id} (workspace: ${existing.workspaceId})`);
|
||||
|
||||
return this.prisma.terminalSession.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: TerminalSessionStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a terminal session by ID.
|
||||
*
|
||||
* @param id - The session ID to retrieve
|
||||
* @returns The TerminalSession record, or null if not found
|
||||
*/
|
||||
async findById(id: string): Promise<TerminalSession | null> {
|
||||
return this.prisma.terminalSession.findUnique({ where: { id } });
|
||||
}
|
||||
}
|
||||
89
apps/api/src/terminal/terminal.dto.ts
Normal file
89
apps/api/src/terminal/terminal.dto.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Terminal DTOs
|
||||
*
|
||||
* Data Transfer Objects for terminal WebSocket events.
|
||||
* Validated using class-validator decorators.
|
||||
*/
|
||||
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
Min,
|
||||
Max,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
} from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for creating a new terminal PTY session.
|
||||
*/
|
||||
export class CreateTerminalDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
cols?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
rows?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4096)
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for sending input data to a terminal PTY session.
|
||||
*/
|
||||
export class TerminalInputDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
sessionId!: string;
|
||||
|
||||
@IsString()
|
||||
data!: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for resizing a terminal PTY session.
|
||||
*/
|
||||
export class TerminalResizeDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
sessionId!: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
cols!: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(200)
|
||||
rows!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* DTO for closing a terminal PTY session.
|
||||
*/
|
||||
export class CloseTerminalDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
sessionId!: string;
|
||||
}
|
||||
501
apps/api/src/terminal/terminal.gateway.spec.ts
Normal file
501
apps/api/src/terminal/terminal.gateway.spec.ts
Normal file
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* TerminalGateway Tests
|
||||
*
|
||||
* Unit tests for WebSocket terminal gateway:
|
||||
* - Authentication on connection
|
||||
* - terminal:create event handling
|
||||
* - terminal:input event handling
|
||||
* - terminal:resize event handling
|
||||
* - terminal:close event handling
|
||||
* - disconnect cleanup
|
||||
* - Error paths
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import type { Socket } from "socket.io";
|
||||
import { TerminalGateway } from "./terminal.gateway";
|
||||
import { TerminalService } from "./terminal.service";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
// ==========================================
|
||||
// Mocks
|
||||
// ==========================================
|
||||
|
||||
// Mock node-pty globally so TerminalService doesn't fail to import
|
||||
vi.mock("node-pty", () => ({
|
||||
spawn: vi.fn(() => ({
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
pid: 1000,
|
||||
})),
|
||||
}));
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
userId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function createMockSocket(id = "test-socket-id"): AuthenticatedSocket {
|
||||
return {
|
||||
id,
|
||||
emit: vi.fn(),
|
||||
join: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
data: {},
|
||||
handshake: {
|
||||
auth: { token: "valid-token" },
|
||||
query: {},
|
||||
headers: {},
|
||||
},
|
||||
} as unknown as AuthenticatedSocket;
|
||||
}
|
||||
|
||||
function createMockAuthService() {
|
||||
return {
|
||||
verifySession: vi.fn().mockResolvedValue({
|
||||
user: { id: "user-123" },
|
||||
session: { id: "session-123" },
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createMockPrismaService() {
|
||||
return {
|
||||
workspaceMember: {
|
||||
findFirst: vi.fn().mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTerminalService() {
|
||||
return {
|
||||
createSession: vi.fn().mockReturnValue({
|
||||
sessionId: "session-uuid-1",
|
||||
name: undefined,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
}),
|
||||
writeToSession: vi.fn(),
|
||||
resizeSession: vi.fn(),
|
||||
closeSession: vi.fn().mockReturnValue(true),
|
||||
closeWorkspaceSessions: vi.fn(),
|
||||
sessionBelongsToWorkspace: vi.fn().mockReturnValue(true),
|
||||
getWorkspaceSessionCount: vi.fn().mockReturnValue(0),
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Tests
|
||||
// ==========================================
|
||||
|
||||
describe("TerminalGateway", () => {
|
||||
let gateway: TerminalGateway;
|
||||
let mockAuthService: ReturnType<typeof createMockAuthService>;
|
||||
let mockPrismaService: ReturnType<typeof createMockPrismaService>;
|
||||
let mockTerminalService: ReturnType<typeof createMockTerminalService>;
|
||||
let mockClient: AuthenticatedSocket;
|
||||
|
||||
beforeEach(() => {
|
||||
mockAuthService = createMockAuthService();
|
||||
mockPrismaService = createMockPrismaService();
|
||||
mockTerminalService = createMockTerminalService();
|
||||
mockClient = createMockSocket();
|
||||
|
||||
gateway = new TerminalGateway(
|
||||
mockAuthService as unknown as AuthService,
|
||||
mockPrismaService as unknown as PrismaService,
|
||||
mockTerminalService as unknown as TerminalService
|
||||
);
|
||||
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleConnection (authentication)
|
||||
// ==========================================
|
||||
describe("handleConnection", () => {
|
||||
it("should authenticate client and join workspace room on valid token", async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({
|
||||
user: { id: "user-123" },
|
||||
});
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockAuthService.verifySession).toHaveBeenCalledWith("valid-token");
|
||||
expect(mockClient.data.userId).toBe("user-123");
|
||||
expect(mockClient.data.workspaceId).toBe("workspace-456");
|
||||
expect(mockClient.join).toHaveBeenCalledWith("terminal:workspace-456");
|
||||
});
|
||||
|
||||
it("should disconnect and emit error if no token provided", async () => {
|
||||
const clientNoToken = createMockSocket("no-token");
|
||||
clientNoToken.handshake = {
|
||||
auth: {},
|
||||
query: {},
|
||||
headers: {},
|
||||
} as typeof clientNoToken.handshake;
|
||||
|
||||
await gateway.handleConnection(clientNoToken);
|
||||
|
||||
expect(clientNoToken.disconnect).toHaveBeenCalled();
|
||||
expect(clientNoToken.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("no token") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should disconnect and emit error if token is invalid", async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue(null);
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("invalid") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should disconnect and emit error if no workspace access", async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue(null);
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("workspace") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should disconnect and emit error if auth throws", async () => {
|
||||
mockAuthService.verifySession.mockRejectedValue(new Error("Auth service down"));
|
||||
|
||||
await gateway.handleConnection(mockClient);
|
||||
|
||||
expect(mockClient.disconnect).toHaveBeenCalled();
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.any(String) })
|
||||
);
|
||||
});
|
||||
|
||||
it("should extract token from handshake.query as fallback", async () => {
|
||||
const clientQueryToken = createMockSocket("query-token-client");
|
||||
clientQueryToken.handshake = {
|
||||
auth: {},
|
||||
query: { token: "query-token" },
|
||||
headers: {},
|
||||
} as typeof clientQueryToken.handshake;
|
||||
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
await gateway.handleConnection(clientQueryToken);
|
||||
|
||||
expect(mockAuthService.verifySession).toHaveBeenCalledWith("query-token");
|
||||
});
|
||||
|
||||
it("should extract token from Authorization header as last fallback", async () => {
|
||||
const clientHeaderToken = createMockSocket("header-token-client");
|
||||
clientHeaderToken.handshake = {
|
||||
auth: {},
|
||||
query: {},
|
||||
headers: { authorization: "Bearer header-token" },
|
||||
} as typeof clientHeaderToken.handshake;
|
||||
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
|
||||
await gateway.handleConnection(clientHeaderToken);
|
||||
|
||||
expect(mockAuthService.verifySession).toHaveBeenCalledWith("header-token");
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleDisconnect
|
||||
// ==========================================
|
||||
describe("handleDisconnect", () => {
|
||||
it("should close all workspace sessions on disconnect", async () => {
|
||||
await gateway.handleConnection(mockClient);
|
||||
vi.clearAllMocks();
|
||||
|
||||
gateway.handleDisconnect(mockClient);
|
||||
|
||||
expect(mockTerminalService.closeWorkspaceSessions).toHaveBeenCalledWith("workspace-456");
|
||||
});
|
||||
|
||||
it("should not throw for unauthenticated client disconnect", () => {
|
||||
const unauthClient = createMockSocket("unauth-disconnect");
|
||||
|
||||
expect(() => gateway.handleDisconnect(unauthClient)).not.toThrow();
|
||||
expect(mockTerminalService.closeWorkspaceSessions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleCreate (terminal:create)
|
||||
// ==========================================
|
||||
describe("handleCreate", () => {
|
||||
beforeEach(async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
await gateway.handleConnection(mockClient);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should create a PTY session and emit terminal:created", async () => {
|
||||
mockTerminalService.createSession.mockReturnValue({
|
||||
sessionId: "new-session-id",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
});
|
||||
|
||||
await gateway.handleCreate(mockClient, {});
|
||||
|
||||
expect(mockTerminalService.createSession).toHaveBeenCalled();
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:created",
|
||||
expect.objectContaining({ sessionId: "new-session-id" })
|
||||
);
|
||||
});
|
||||
|
||||
it("should pass cols, rows, cwd, name to service", async () => {
|
||||
await gateway.handleCreate(mockClient, {
|
||||
cols: 132,
|
||||
rows: 50,
|
||||
cwd: "/home/user",
|
||||
name: "my-shell",
|
||||
});
|
||||
|
||||
expect(mockTerminalService.createSession).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ cols: 132, rows: 50, cwd: "/home/user", name: "my-shell" })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error if not authenticated", async () => {
|
||||
const unauthClient = createMockSocket("unauth");
|
||||
|
||||
await gateway.handleCreate(unauthClient, {});
|
||||
|
||||
expect(unauthClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error if service throws (session limit)", async () => {
|
||||
mockTerminalService.createSession.mockImplementation(() => {
|
||||
throw new Error("Workspace has reached the maximum of 10 concurrent terminal sessions");
|
||||
});
|
||||
|
||||
await gateway.handleCreate(mockClient, {});
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("maximum") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error for invalid payload (negative cols)", async () => {
|
||||
await gateway.handleCreate(mockClient, { cols: -1 });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleInput (terminal:input)
|
||||
// ==========================================
|
||||
describe("handleInput", () => {
|
||||
beforeEach(async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
await gateway.handleConnection(mockClient);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should write data to the PTY session", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||
|
||||
await gateway.handleInput(mockClient, { sessionId: "sess-1", data: "ls\n" });
|
||||
|
||||
expect(mockTerminalService.writeToSession).toHaveBeenCalledWith("sess-1", "ls\n");
|
||||
});
|
||||
|
||||
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||
|
||||
await gateway.handleInput(mockClient, { sessionId: "alien-sess", data: "data" });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||
);
|
||||
expect(mockTerminalService.writeToSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should emit terminal:error if not authenticated", async () => {
|
||||
const unauthClient = createMockSocket("unauth");
|
||||
|
||||
await gateway.handleInput(unauthClient, { sessionId: "sess-1", data: "x" });
|
||||
|
||||
expect(unauthClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("authenticated") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
||||
await gateway.handleInput(mockClient, { data: "some input" });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleResize (terminal:resize)
|
||||
// ==========================================
|
||||
describe("handleResize", () => {
|
||||
beforeEach(async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
await gateway.handleConnection(mockClient);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should resize the PTY session", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||
|
||||
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 120, rows: 40 });
|
||||
|
||||
expect(mockTerminalService.resizeSession).toHaveBeenCalledWith("sess-1", 120, 40);
|
||||
});
|
||||
|
||||
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||
|
||||
await gateway.handleResize(mockClient, { sessionId: "alien-sess", cols: 80, rows: 24 });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error for invalid payload (cols too large)", async () => {
|
||||
await gateway.handleResize(mockClient, { sessionId: "sess-1", cols: 9999, rows: 24 });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// handleClose (terminal:close)
|
||||
// ==========================================
|
||||
describe("handleClose", () => {
|
||||
beforeEach(async () => {
|
||||
mockAuthService.verifySession.mockResolvedValue({ user: { id: "user-123" } });
|
||||
mockPrismaService.workspaceMember.findFirst.mockResolvedValue({
|
||||
userId: "user-123",
|
||||
workspaceId: "workspace-456",
|
||||
role: "MEMBER",
|
||||
});
|
||||
await gateway.handleConnection(mockClient);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should close an existing PTY session", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||
mockTerminalService.closeSession.mockReturnValue(true);
|
||||
|
||||
await gateway.handleClose(mockClient, { sessionId: "sess-1" });
|
||||
|
||||
expect(mockTerminalService.closeSession).toHaveBeenCalledWith("sess-1");
|
||||
});
|
||||
|
||||
it("should emit terminal:error if session does not belong to workspace", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(false);
|
||||
|
||||
await gateway.handleClose(mockClient, { sessionId: "alien-sess" });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error if closeSession returns false (session gone)", async () => {
|
||||
mockTerminalService.sessionBelongsToWorkspace.mockReturnValue(true);
|
||||
mockTerminalService.closeSession.mockReturnValue(false);
|
||||
|
||||
await gateway.handleClose(mockClient, { sessionId: "gone-sess" });
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("not found") })
|
||||
);
|
||||
});
|
||||
|
||||
it("should emit terminal:error for invalid payload (missing sessionId)", async () => {
|
||||
await gateway.handleClose(mockClient, {});
|
||||
|
||||
expect(mockClient.emit).toHaveBeenCalledWith(
|
||||
"terminal:error",
|
||||
expect.objectContaining({ message: expect.stringContaining("Invalid payload") })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
423
apps/api/src/terminal/terminal.gateway.ts
Normal file
423
apps/api/src/terminal/terminal.gateway.ts
Normal file
@@ -0,0 +1,423 @@
|
||||
/**
|
||||
* TerminalGateway
|
||||
*
|
||||
* WebSocket gateway for real-time PTY terminal sessions.
|
||||
* Uses the `/terminal` namespace to keep terminal traffic separate
|
||||
* from the main WebSocket gateway.
|
||||
*
|
||||
* Protocol:
|
||||
* 1. Client connects with auth token in handshake
|
||||
* 2. Client emits `terminal:create` to spawn a new PTY session
|
||||
* 3. Server emits `terminal:created` with { sessionId }
|
||||
* 4. Client emits `terminal:input` with { sessionId, data } to send keystrokes
|
||||
* 5. Server emits `terminal:output` with { sessionId, data } for stdout/stderr
|
||||
* 6. Client emits `terminal:resize` with { sessionId, cols, rows } on window resize
|
||||
* 7. Client emits `terminal:close` with { sessionId } to terminate the PTY
|
||||
* 8. Server emits `terminal:exit` with { sessionId, exitCode, signal } on PTY exit
|
||||
*
|
||||
* Authentication:
|
||||
* - Same pattern as websocket.gateway.ts and speech.gateway.ts
|
||||
* - Token extracted from handshake.auth.token / query.token / Authorization header
|
||||
*
|
||||
* Workspace isolation:
|
||||
* - Clients join room `terminal:{workspaceId}` on connect
|
||||
* - Sessions are scoped to workspace; cross-workspace access is denied
|
||||
*/
|
||||
|
||||
import {
|
||||
WebSocketGateway as WSGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
} from "@nestjs/websockets";
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { TerminalService } from "./terminal.service";
|
||||
import {
|
||||
CreateTerminalDto,
|
||||
TerminalInputDto,
|
||||
TerminalResizeDto,
|
||||
CloseTerminalDto,
|
||||
} from "./terminal.dto";
|
||||
import { validate } from "class-validator";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
|
||||
// ==========================================
|
||||
// Types
|
||||
// ==========================================
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
data: {
|
||||
userId?: string;
|
||||
workspaceId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Gateway
|
||||
// ==========================================
|
||||
|
||||
@WSGateway({
|
||||
namespace: "/terminal",
|
||||
cors: {
|
||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class TerminalGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
@WebSocketServer()
|
||||
server!: Server;
|
||||
|
||||
private readonly logger = new Logger(TerminalGateway.name);
|
||||
private readonly CONNECTION_TIMEOUT_MS = 5000;
|
||||
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly terminalService: TerminalService
|
||||
) {}
|
||||
|
||||
// ==========================================
|
||||
// Connection lifecycle
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Authenticate client on connection using handshake token.
|
||||
* Validates workspace membership and joins the workspace-scoped room.
|
||||
*/
|
||||
async handleConnection(client: Socket): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!authenticatedClient.data.userId) {
|
||||
this.logger.warn(
|
||||
`Terminal client ${authenticatedClient.id} timed out during authentication`
|
||||
);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication timed out.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
}
|
||||
}, this.CONNECTION_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const token = this.extractTokenFromHandshake(authenticatedClient);
|
||||
|
||||
if (!token) {
|
||||
this.logger.warn(`Terminal client ${authenticatedClient.id} connected without token`);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: no token provided.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionData = await this.authService.verifySession(token);
|
||||
|
||||
if (!sessionData) {
|
||||
this.logger.warn(`Terminal client ${authenticatedClient.id} has invalid token`);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: invalid or expired token.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
const user = sessionData.user as { id: string };
|
||||
const userId = user.id;
|
||||
|
||||
const workspaceMembership = await this.prisma.workspaceMember.findFirst({
|
||||
where: { userId },
|
||||
select: { workspaceId: true, userId: true, role: true },
|
||||
});
|
||||
|
||||
if (!workspaceMembership) {
|
||||
this.logger.warn(`Terminal user ${userId} has no workspace access`);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: no workspace access.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
|
||||
authenticatedClient.data.userId = userId;
|
||||
authenticatedClient.data.workspaceId = workspaceMembership.workspaceId;
|
||||
|
||||
// Join workspace-scoped terminal room
|
||||
const room = this.getWorkspaceRoom(workspaceMembership.workspaceId);
|
||||
await authenticatedClient.join(room);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
this.logger.log(
|
||||
`Terminal client ${authenticatedClient.id} connected (user: ${userId}, workspace: ${workspaceMembership.workspaceId})`
|
||||
);
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
this.logger.error(
|
||||
`Authentication failed for terminal client ${authenticatedClient.id}:`,
|
||||
error instanceof Error ? error.message : "Unknown error"
|
||||
);
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Authentication failed: an unexpected error occurred.",
|
||||
});
|
||||
authenticatedClient.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all PTY sessions for this client's workspace on disconnect.
|
||||
*/
|
||||
handleDisconnect(client: Socket): void {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { workspaceId, userId } = authenticatedClient.data;
|
||||
|
||||
if (workspaceId) {
|
||||
this.terminalService.closeWorkspaceSessions(workspaceId);
|
||||
|
||||
const room = this.getWorkspaceRoom(workspaceId);
|
||||
void authenticatedClient.leave(room);
|
||||
this.logger.log(
|
||||
`Terminal client ${authenticatedClient.id} disconnected (user: ${userId ?? "unknown"}, workspace: ${workspaceId})`
|
||||
);
|
||||
} else {
|
||||
this.logger.debug(`Terminal client ${authenticatedClient.id} disconnected (unauthenticated)`);
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Terminal events
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Spawn a new PTY session for the connected client.
|
||||
*
|
||||
* Emits `terminal:created` with { sessionId, name, cols, rows } on success.
|
||||
* Emits `terminal:error` on failure.
|
||||
*/
|
||||
@SubscribeMessage("terminal:create")
|
||||
async handleCreate(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate DTO
|
||||
const dto = plainToInstance(CreateTerminalDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = this.terminalService.createSession(authenticatedClient, {
|
||||
workspaceId,
|
||||
socketId: authenticatedClient.id,
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.cols !== undefined ? { cols: dto.cols } : {}),
|
||||
...(dto.rows !== undefined ? { rows: dto.rows } : {}),
|
||||
...(dto.cwd !== undefined ? { cwd: dto.cwd } : {}),
|
||||
});
|
||||
|
||||
authenticatedClient.emit("terminal:created", {
|
||||
sessionId: result.sessionId,
|
||||
name: result.name,
|
||||
cols: result.cols,
|
||||
rows: result.rows,
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Terminal session ${result.sessionId} created for client ${authenticatedClient.id} (workspace: ${workspaceId})`
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.error(
|
||||
`Failed to create terminal session for client ${authenticatedClient.id}: ${message}`
|
||||
);
|
||||
authenticatedClient.emit("terminal:error", { message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write input data to an existing PTY session.
|
||||
*
|
||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||
*/
|
||||
@SubscribeMessage("terminal:input")
|
||||
async handleInput(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dto = plainToInstance(TerminalInputDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.terminalService.writeToSession(dto.sessionId, dto.data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Failed to write to terminal session ${dto.sessionId}: ${message}`);
|
||||
authenticatedClient.emit("terminal:error", { message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize an existing PTY session.
|
||||
*
|
||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||
*/
|
||||
@SubscribeMessage("terminal:resize")
|
||||
async handleResize(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dto = plainToInstance(TerminalResizeDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.terminalService.resizeSession(dto.sessionId, dto.cols, dto.rows);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Failed to resize terminal session ${dto.sessionId}: ${message}`);
|
||||
authenticatedClient.emit("terminal:error", { message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill and close an existing PTY session.
|
||||
*
|
||||
* Emits `terminal:error` if the session is not found or unauthorized.
|
||||
*/
|
||||
@SubscribeMessage("terminal:close")
|
||||
async handleClose(client: Socket, payload: unknown): Promise<void> {
|
||||
const authenticatedClient = client as AuthenticatedSocket;
|
||||
const { userId, workspaceId } = authenticatedClient.data;
|
||||
|
||||
if (!userId || !workspaceId) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: "Not authenticated. Connect with a valid token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dto = plainToInstance(CloseTerminalDto, payload ?? {});
|
||||
const errors = await validate(dto);
|
||||
if (errors.length > 0) {
|
||||
const messages = errors.map((e) => Object.values(e.constraints ?? {}).join(", ")).join("; ");
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Invalid payload: ${messages}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.terminalService.sessionBelongsToWorkspace(dto.sessionId, workspaceId)) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found or unauthorized.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const closed = this.terminalService.closeSession(dto.sessionId);
|
||||
if (!closed) {
|
||||
authenticatedClient.emit("terminal:error", {
|
||||
message: `Terminal session ${dto.sessionId} not found.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(`Terminal session ${dto.sessionId} closed by client ${authenticatedClient.id}`);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Private helpers
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Extract authentication token from Socket.IO handshake.
|
||||
* Checks auth.token, query.token, and Authorization header (in that order).
|
||||
*/
|
||||
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||
const authToken = client.handshake.auth.token as unknown;
|
||||
if (typeof authToken === "string" && authToken.length > 0) {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
const queryToken = client.handshake.query.token as unknown;
|
||||
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||
return queryToken;
|
||||
}
|
||||
|
||||
const authHeader = client.handshake.headers.authorization as unknown;
|
||||
if (typeof authHeader === "string") {
|
||||
const parts = authHeader.split(" ");
|
||||
const [type, token] = parts;
|
||||
if (type === "Bearer" && token) {
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the workspace-scoped room name for the terminal namespace.
|
||||
*/
|
||||
private getWorkspaceRoom(workspaceId: string): string {
|
||||
return `terminal:${workspaceId}`;
|
||||
}
|
||||
}
|
||||
31
apps/api/src/terminal/terminal.module.ts
Normal file
31
apps/api/src/terminal/terminal.module.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* TerminalModule
|
||||
*
|
||||
* NestJS module for WebSocket-based terminal sessions via node-pty.
|
||||
*
|
||||
* Imports:
|
||||
* - AuthModule for WebSocket authentication (verifySession)
|
||||
* - PrismaModule for workspace membership queries and session persistence
|
||||
*
|
||||
* Providers:
|
||||
* - TerminalService: manages PTY session lifecycle (in-memory)
|
||||
* - TerminalSessionService: persists session records to the database
|
||||
* - TerminalGateway: WebSocket gateway on /terminal namespace
|
||||
*
|
||||
* The module does not export providers; terminal sessions are
|
||||
* self-contained within this module.
|
||||
*/
|
||||
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TerminalGateway } from "./terminal.gateway";
|
||||
import { TerminalService } from "./terminal.service";
|
||||
import { TerminalSessionService } from "./terminal-session.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { PrismaModule } from "../prisma/prisma.module";
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
providers: [TerminalGateway, TerminalService, TerminalSessionService],
|
||||
exports: [TerminalSessionService],
|
||||
})
|
||||
export class TerminalModule {}
|
||||
339
apps/api/src/terminal/terminal.service.spec.ts
Normal file
339
apps/api/src/terminal/terminal.service.spec.ts
Normal file
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* TerminalService Tests
|
||||
*
|
||||
* Unit tests for PTY session management: create, write, resize, close,
|
||||
* workspace cleanup, and access control.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import type { Socket } from "socket.io";
|
||||
import { TerminalService, MAX_SESSIONS_PER_WORKSPACE } from "./terminal.service";
|
||||
|
||||
// ==========================================
|
||||
// Mocks
|
||||
// ==========================================
|
||||
|
||||
// Mock node-pty before importing service
|
||||
const mockPtyProcess = {
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
pid: 12345,
|
||||
};
|
||||
|
||||
vi.mock("node-pty", () => ({
|
||||
spawn: vi.fn(() => mockPtyProcess),
|
||||
}));
|
||||
|
||||
function createMockSocket(id = "socket-1"): Socket {
|
||||
return {
|
||||
id,
|
||||
emit: vi.fn(),
|
||||
join: vi.fn(),
|
||||
leave: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
data: {},
|
||||
} as unknown as Socket;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Tests
|
||||
// ==========================================
|
||||
|
||||
describe("TerminalService", () => {
|
||||
let service: TerminalService;
|
||||
let mockSocket: Socket;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mock implementations
|
||||
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
|
||||
mockPtyProcess.onExit.mockImplementation(
|
||||
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
|
||||
);
|
||||
service = new TerminalService();
|
||||
// Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock)
|
||||
await service.onModuleInit();
|
||||
mockSocket = createMockSocket();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// createSession
|
||||
// ==========================================
|
||||
describe("createSession", () => {
|
||||
it("should create a PTY session and return sessionId", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(result.sessionId).toBeDefined();
|
||||
expect(typeof result.sessionId).toBe("string");
|
||||
expect(result.cols).toBe(80);
|
||||
expect(result.rows).toBe(24);
|
||||
});
|
||||
|
||||
it("should use provided cols and rows", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
});
|
||||
|
||||
expect(result.cols).toBe(120);
|
||||
expect(result.rows).toBe(40);
|
||||
});
|
||||
|
||||
it("should return the provided session name", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
name: "my-terminal",
|
||||
});
|
||||
|
||||
expect(result.name).toBe("my-terminal");
|
||||
});
|
||||
|
||||
it("should wire PTY onData to emit terminal:output", () => {
|
||||
let dataCallback: ((data: string) => void) | undefined;
|
||||
mockPtyProcess.onData.mockImplementation((cb: (data: string) => void) => {
|
||||
dataCallback = cb;
|
||||
});
|
||||
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(dataCallback).toBeDefined();
|
||||
dataCallback!("hello world");
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:output", {
|
||||
sessionId: result.sessionId,
|
||||
data: "hello world",
|
||||
});
|
||||
});
|
||||
|
||||
it("should wire PTY onExit to emit terminal:exit and cleanup", () => {
|
||||
let exitCallback: ((e: { exitCode: number; signal?: number }) => void) | undefined;
|
||||
mockPtyProcess.onExit.mockImplementation(
|
||||
(cb: (e: { exitCode: number; signal?: number }) => void) => {
|
||||
exitCallback = cb;
|
||||
}
|
||||
);
|
||||
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(exitCallback).toBeDefined();
|
||||
exitCallback!({ exitCode: 0 });
|
||||
|
||||
expect(mockSocket.emit).toHaveBeenCalledWith("terminal:exit", {
|
||||
sessionId: result.sessionId,
|
||||
exitCode: 0,
|
||||
signal: undefined,
|
||||
});
|
||||
|
||||
// Session should be cleaned up
|
||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("should throw when workspace session limit is reached", () => {
|
||||
const limit = MAX_SESSIONS_PER_WORKSPACE;
|
||||
|
||||
for (let i = 0; i < limit; i++) {
|
||||
service.createSession(createMockSocket(`socket-${String(i)}`), {
|
||||
workspaceId: "ws-limit",
|
||||
socketId: `socket-${String(i)}`,
|
||||
});
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
service.createSession(createMockSocket("socket-overflow"), {
|
||||
workspaceId: "ws-limit",
|
||||
socketId: "socket-overflow",
|
||||
})
|
||||
).toThrow(/maximum/i);
|
||||
});
|
||||
|
||||
it("should allow sessions in different workspaces independently", () => {
|
||||
service.createSession(mockSocket, { workspaceId: "ws-a", socketId: "s1" });
|
||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-b", socketId: "s2" });
|
||||
|
||||
expect(service.getWorkspaceSessionCount("ws-a")).toBe(1);
|
||||
expect(service.getWorkspaceSessionCount("ws-b")).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// writeToSession
|
||||
// ==========================================
|
||||
describe("writeToSession", () => {
|
||||
it("should write data to PTY", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
service.writeToSession(result.sessionId, "ls -la\n");
|
||||
|
||||
expect(mockPtyProcess.write).toHaveBeenCalledWith("ls -la\n");
|
||||
});
|
||||
|
||||
it("should throw for unknown sessionId", () => {
|
||||
expect(() => service.writeToSession("nonexistent-id", "data")).toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// resizeSession
|
||||
// ==========================================
|
||||
describe("resizeSession", () => {
|
||||
it("should resize PTY dimensions", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
service.resizeSession(result.sessionId, 132, 50);
|
||||
|
||||
expect(mockPtyProcess.resize).toHaveBeenCalledWith(132, 50);
|
||||
});
|
||||
|
||||
it("should throw for unknown sessionId", () => {
|
||||
expect(() => service.resizeSession("nonexistent-id", 80, 24)).toThrow(/not found/i);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// closeSession
|
||||
// ==========================================
|
||||
describe("closeSession", () => {
|
||||
it("should kill PTY and return true for existing session", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
const closed = service.closeSession(result.sessionId);
|
||||
|
||||
expect(closed).toBe(true);
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalled();
|
||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for nonexistent sessionId", () => {
|
||||
const closed = service.closeSession("does-not-exist");
|
||||
expect(closed).toBe(false);
|
||||
});
|
||||
|
||||
it("should clean up workspace tracking after close", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(1);
|
||||
service.closeSession(result.sessionId);
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||
});
|
||||
|
||||
it("should not throw if PTY kill throws", () => {
|
||||
mockPtyProcess.kill.mockImplementationOnce(() => {
|
||||
throw new Error("PTY already dead");
|
||||
});
|
||||
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(() => service.closeSession(result.sessionId)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// closeWorkspaceSessions
|
||||
// ==========================================
|
||||
describe("closeWorkspaceSessions", () => {
|
||||
it("should kill all sessions for a workspace", () => {
|
||||
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
|
||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-1", socketId: "s2" });
|
||||
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(2);
|
||||
|
||||
service.closeWorkspaceSessions("ws-1");
|
||||
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||
expect(mockPtyProcess.kill).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should not affect sessions in other workspaces", () => {
|
||||
service.createSession(mockSocket, { workspaceId: "ws-1", socketId: "s1" });
|
||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-2", socketId: "s2" });
|
||||
|
||||
service.closeWorkspaceSessions("ws-1");
|
||||
|
||||
expect(service.getWorkspaceSessionCount("ws-1")).toBe(0);
|
||||
expect(service.getWorkspaceSessionCount("ws-2")).toBe(1);
|
||||
});
|
||||
|
||||
it("should not throw for workspaces with no sessions", () => {
|
||||
expect(() => service.closeWorkspaceSessions("ws-nonexistent")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// sessionBelongsToWorkspace
|
||||
// ==========================================
|
||||
describe("sessionBelongsToWorkspace", () => {
|
||||
it("should return true for a session belonging to the workspace", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for a session in a different workspace", () => {
|
||||
const result = service.createSession(mockSocket, {
|
||||
workspaceId: "ws-1",
|
||||
socketId: "socket-1",
|
||||
});
|
||||
|
||||
expect(service.sessionBelongsToWorkspace(result.sessionId, "ws-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for a nonexistent sessionId", () => {
|
||||
expect(service.sessionBelongsToWorkspace("no-such-id", "ws-1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ==========================================
|
||||
// getWorkspaceSessionCount
|
||||
// ==========================================
|
||||
describe("getWorkspaceSessionCount", () => {
|
||||
it("should return 0 for workspace with no sessions", () => {
|
||||
expect(service.getWorkspaceSessionCount("empty-ws")).toBe(0);
|
||||
});
|
||||
|
||||
it("should track session count accurately", () => {
|
||||
service.createSession(mockSocket, { workspaceId: "ws-count", socketId: "s1" });
|
||||
expect(service.getWorkspaceSessionCount("ws-count")).toBe(1);
|
||||
|
||||
service.createSession(createMockSocket("s2"), { workspaceId: "ws-count", socketId: "s2" });
|
||||
expect(service.getWorkspaceSessionCount("ws-count")).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
276
apps/api/src/terminal/terminal.service.ts
Normal file
276
apps/api/src/terminal/terminal.service.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* TerminalService
|
||||
*
|
||||
* Manages PTY (pseudo-terminal) sessions for workspace users.
|
||||
* Spawns real shell processes via node-pty, streams I/O to connected sockets,
|
||||
* and enforces per-workspace session limits.
|
||||
*
|
||||
* Session lifecycle:
|
||||
* - createSession: spawn a new PTY, wire onData/onExit, return sessionId
|
||||
* - writeToSession: send input data to PTY stdin
|
||||
* - resizeSession: resize PTY dimensions (cols x rows)
|
||||
* - closeSession: kill PTY process, emit terminal:exit, cleanup
|
||||
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
|
||||
*/
|
||||
|
||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import type { IPty } from "node-pty";
|
||||
import type { Socket } from "socket.io";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Lazy-loaded in onModuleInit via dynamic import() to prevent crash
|
||||
// if the native binary is missing. node-pty requires a compiled .node
|
||||
// binary which may not be available in all Docker environments.
|
||||
interface NodePtyModule {
|
||||
spawn: (file: string, args: string[], options: Record<string, unknown>) => IPty;
|
||||
}
|
||||
let pty: NodePtyModule | null = null;
|
||||
|
||||
/** Maximum concurrent PTY sessions per workspace */
|
||||
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
|
||||
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
|
||||
10
|
||||
);
|
||||
|
||||
/** Default PTY dimensions */
|
||||
const DEFAULT_COLS = 80;
|
||||
const DEFAULT_ROWS = 24;
|
||||
|
||||
export interface TerminalSession {
|
||||
sessionId: string;
|
||||
workspaceId: string;
|
||||
pty: IPty;
|
||||
name?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface CreateSessionOptions {
|
||||
name?: string;
|
||||
cols?: number;
|
||||
rows?: number;
|
||||
cwd?: string;
|
||||
workspaceId: string;
|
||||
socketId: string;
|
||||
}
|
||||
|
||||
export interface SessionCreatedResult {
|
||||
sessionId: string;
|
||||
name?: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TerminalService implements OnModuleInit {
|
||||
private readonly logger = new Logger(TerminalService.name);
|
||||
|
||||
/**
|
||||
* Map of sessionId -> TerminalSession
|
||||
*/
|
||||
private readonly sessions = new Map<string, TerminalSession>();
|
||||
|
||||
/**
|
||||
* Map of workspaceId -> Set<sessionId> for fast per-workspace lookups
|
||||
*/
|
||||
private readonly workspaceSessions = new Map<string, Set<string>>();
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (!pty) {
|
||||
try {
|
||||
pty = await import("node-pty");
|
||||
this.logger.log("node-pty loaded successfully — terminal sessions available");
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
"node-pty native module not available — terminal sessions will be disabled. " +
|
||||
"Install build tools (python3, make, g++) and rebuild node-pty to enable."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new PTY session for the given workspace and socket.
|
||||
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
|
||||
*
|
||||
* @throws Error if workspace session limit is exceeded or node-pty is unavailable
|
||||
*/
|
||||
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
|
||||
if (!pty) {
|
||||
throw new Error("Terminal sessions are unavailable: node-pty native module failed to load");
|
||||
}
|
||||
const { workspaceId, name, cwd, socketId } = options;
|
||||
const cols = options.cols ?? DEFAULT_COLS;
|
||||
const rows = options.rows ?? DEFAULT_ROWS;
|
||||
|
||||
// Enforce per-workspace session limit
|
||||
const workspaceSessionIds = this.workspaceSessions.get(workspaceId) ?? new Set<string>();
|
||||
if (workspaceSessionIds.size >= MAX_SESSIONS_PER_WORKSPACE) {
|
||||
throw new Error(
|
||||
`Workspace ${workspaceId} has reached the maximum of ${String(MAX_SESSIONS_PER_WORKSPACE)} concurrent terminal sessions`
|
||||
);
|
||||
}
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const shell = process.env.SHELL ?? "/bin/bash";
|
||||
|
||||
this.logger.log(
|
||||
`Spawning PTY session ${sessionId} for workspace ${workspaceId} (socket: ${socketId}, shell: ${shell}, ${String(cols)}x${String(rows)})`
|
||||
);
|
||||
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
cwd: cwd ?? process.cwd(),
|
||||
env: process.env as Record<string, string>,
|
||||
});
|
||||
|
||||
const session: TerminalSession = {
|
||||
sessionId,
|
||||
workspaceId,
|
||||
pty: ptyProcess,
|
||||
...(name !== undefined ? { name } : {}),
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
// Track by workspace
|
||||
if (!this.workspaceSessions.has(workspaceId)) {
|
||||
this.workspaceSessions.set(workspaceId, new Set());
|
||||
}
|
||||
const wsSet = this.workspaceSessions.get(workspaceId);
|
||||
if (wsSet) {
|
||||
wsSet.add(sessionId);
|
||||
}
|
||||
|
||||
// Wire PTY stdout/stderr -> terminal:output
|
||||
ptyProcess.onData((data: string) => {
|
||||
socket.emit("terminal:output", { sessionId, data });
|
||||
});
|
||||
|
||||
// Wire PTY exit -> terminal:exit, cleanup
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
this.logger.log(
|
||||
`PTY session ${sessionId} exited (exitCode: ${String(exitCode)}, signal: ${String(signal ?? "none")})`
|
||||
);
|
||||
socket.emit("terminal:exit", { sessionId, exitCode, signal });
|
||||
this.cleanupSession(sessionId, workspaceId);
|
||||
});
|
||||
|
||||
return { sessionId, ...(name !== undefined ? { name } : {}), cols, rows };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write input data to a PTY session's stdin.
|
||||
*
|
||||
* @throws Error if session not found
|
||||
*/
|
||||
writeToSession(sessionId: string, data: string): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Terminal session ${sessionId} not found`);
|
||||
}
|
||||
session.pty.write(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resize a PTY session's terminal dimensions.
|
||||
*
|
||||
* @throws Error if session not found
|
||||
*/
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Terminal session ${sessionId} not found`);
|
||||
}
|
||||
session.pty.resize(cols, rows);
|
||||
this.logger.debug(`Resized PTY session ${sessionId} to ${String(cols)}x${String(rows)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill and clean up a specific PTY session.
|
||||
* Returns true if the session existed, false if it was already gone.
|
||||
*/
|
||||
closeSession(sessionId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.logger.log(`Closing PTY session ${sessionId} for workspace ${session.workspaceId}`);
|
||||
|
||||
try {
|
||||
session.pty.kill();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Error killing PTY session ${sessionId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
this.cleanupSession(sessionId, session.workspaceId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all PTY sessions for a workspace (called on client disconnect).
|
||||
*/
|
||||
closeWorkspaceSessions(workspaceId: string): void {
|
||||
const sessionIds = this.workspaceSessions.get(workspaceId);
|
||||
if (!sessionIds || sessionIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Closing ${String(sessionIds.size)} PTY session(s) for workspace ${workspaceId} (disconnect)`
|
||||
);
|
||||
|
||||
// Copy to array to avoid mutation during iteration
|
||||
const ids = Array.from(sessionIds);
|
||||
for (const sessionId of ids) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (session) {
|
||||
try {
|
||||
session.pty.kill();
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Error killing PTY session ${sessionId} on disconnect: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
this.cleanupSession(sessionId, workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of active sessions for a workspace.
|
||||
*/
|
||||
getWorkspaceSessionCount(workspaceId: string): number {
|
||||
return this.workspaceSessions.get(workspaceId)?.size ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session belongs to a given workspace.
|
||||
* Used for access control in the gateway.
|
||||
*/
|
||||
sessionBelongsToWorkspace(sessionId: string, workspaceId: string): boolean {
|
||||
const session = this.sessions.get(sessionId);
|
||||
return session?.workspaceId === workspaceId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal cleanup: remove session from tracking maps.
|
||||
* Does NOT kill the PTY (caller is responsible).
|
||||
*/
|
||||
private cleanupSession(sessionId: string, workspaceId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
|
||||
const workspaceSessionIds = this.workspaceSessions.get(workspaceId);
|
||||
if (workspaceSessionIds) {
|
||||
workspaceSessionIds.delete(sessionId);
|
||||
if (workspaceSessionIds.size === 0) {
|
||||
this.workspaceSessions.delete(workspaceId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
112
apps/api/src/users/preferences.controller.spec.ts
Normal file
112
apps/api/src/users/preferences.controller.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { UnauthorizedException } from "@nestjs/common";
|
||||
import { PreferencesController } from "./preferences.controller";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import type { UpdatePreferencesDto, PreferencesResponseDto } from "./dto";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
|
||||
describe("PreferencesController", () => {
|
||||
let controller: PreferencesController;
|
||||
let service: PreferencesService;
|
||||
|
||||
const mockPreferencesService = {
|
||||
getPreferences: vi.fn(),
|
||||
updatePreferences: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUserId = "user-uuid-123";
|
||||
|
||||
const mockPreferencesResponse: PreferencesResponseDto = {
|
||||
id: "pref-uuid-456",
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
function makeRequest(userId?: string): AuthenticatedRequest {
|
||||
return {
|
||||
user: userId ? { id: userId } : undefined,
|
||||
} as unknown as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
service = mockPreferencesService as unknown as PreferencesService;
|
||||
controller = new PreferencesController(service);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/users/me/preferences", () => {
|
||||
it("should return preferences for authenticated user", async () => {
|
||||
mockPreferencesService.getPreferences.mockResolvedValue(mockPreferencesResponse);
|
||||
|
||||
const result = await controller.getPreferences(makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(mockPreferencesResponse);
|
||||
expect(mockPreferencesService.getPreferences).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.getPreferences(makeRequest())).rejects.toThrow(UnauthorizedException);
|
||||
expect(mockPreferencesService.getPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/users/me/preferences", () => {
|
||||
const updateDto: UpdatePreferencesDto = {
|
||||
theme: "dark",
|
||||
locale: "fr",
|
||||
timezone: "Europe/Paris",
|
||||
};
|
||||
|
||||
it("should update and return preferences for authenticated user", async () => {
|
||||
const updatedResponse: PreferencesResponseDto = {
|
||||
...mockPreferencesResponse,
|
||||
theme: "dark",
|
||||
locale: "fr",
|
||||
timezone: "Europe/Paris",
|
||||
};
|
||||
mockPreferencesService.updatePreferences.mockResolvedValue(updatedResponse);
|
||||
|
||||
const result = await controller.updatePreferences(updateDto, makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(updatedResponse);
|
||||
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, updateDto);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.updatePreferences(updateDto, makeRequest())).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/users/me/preferences", () => {
|
||||
const patchDto: UpdatePreferencesDto = {
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
it("should partially update and return preferences for authenticated user", async () => {
|
||||
const patchedResponse: PreferencesResponseDto = {
|
||||
...mockPreferencesResponse,
|
||||
theme: "light",
|
||||
};
|
||||
mockPreferencesService.updatePreferences.mockResolvedValue(patchedResponse);
|
||||
|
||||
const result = await controller.patchPreferences(patchDto, makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(patchedResponse);
|
||||
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, patchDto);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.patchPreferences(patchDto, makeRequest())).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Patch,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
@@ -38,7 +39,7 @@ export class PreferencesController {
|
||||
|
||||
/**
|
||||
* PUT /api/users/me/preferences
|
||||
* Update current user's preferences
|
||||
* Full replace of current user's preferences
|
||||
*/
|
||||
@Put()
|
||||
async updatePreferences(
|
||||
@@ -53,4 +54,22 @@ export class PreferencesController {
|
||||
|
||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/users/me/preferences
|
||||
* Partial update of current user's preferences
|
||||
*/
|
||||
@Patch()
|
||||
async patchPreferences(
|
||||
@Body() updatePreferencesDto: UpdatePreferencesDto,
|
||||
@Request() req: AuthenticatedRequest
|
||||
) {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||
}
|
||||
}
|
||||
|
||||
141
apps/api/src/users/preferences.service.spec.ts
Normal file
141
apps/api/src/users/preferences.service.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import type { PrismaService } from "../prisma/prisma.service";
|
||||
import type { UpdatePreferencesDto } from "./dto";
|
||||
|
||||
describe("PreferencesService", () => {
|
||||
let service: PreferencesService;
|
||||
|
||||
const mockPrisma = {
|
||||
userPreference: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockUserId = "user-uuid-123";
|
||||
|
||||
const mockDbPreference = {
|
||||
id: "pref-uuid-456",
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PreferencesService(mockPrisma as unknown as PrismaService);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getPreferences", () => {
|
||||
it("should return existing preferences", async () => {
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
|
||||
const result = await service.getPreferences(mockUserId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: mockDbPreference.id,
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
});
|
||||
expect(mockPrisma.userPreference.findUnique).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create default preferences when none exist", async () => {
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.userPreference.create.mockResolvedValue(mockDbPreference);
|
||||
|
||||
const result = await service.getPreferences(mockUserId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: mockDbPreference.id,
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePreferences", () => {
|
||||
it("should update existing preferences", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { theme: "dark", locale: "fr" };
|
||||
const updatedPreference = { ...mockDbPreference, theme: "dark", locale: "fr" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result).toMatchObject({ theme: "dark", locale: "fr" });
|
||||
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
data: expect.objectContaining({ theme: "dark", locale: "fr" }),
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create preferences when updating non-existent record", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { theme: "light" };
|
||||
const createdPreference = { ...mockDbPreference, theme: "light" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.userPreference.create.mockResolvedValue(createdPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result).toMatchObject({ theme: "light" });
|
||||
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: mockUserId,
|
||||
theme: "light",
|
||||
}),
|
||||
});
|
||||
expect(mockPrisma.userPreference.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle timezone update", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { timezone: "America/New_York" };
|
||||
const updatedPreference = { ...mockDbPreference, timezone: "America/New_York" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result.timezone).toBe("America/New_York");
|
||||
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
data: expect.objectContaining({ timezone: "America/New_York" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle settings update", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { settings: { notifications: true } };
|
||||
const updatedPreference = { ...mockDbPreference, settings: { notifications: true } };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result.settings).toEqual({ notifications: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { getTrustedOrigins } from "../auth/auth.config";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
@@ -77,7 +78,7 @@ interface StepOutputData {
|
||||
*/
|
||||
@WSGateway({
|
||||
cors: {
|
||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||
origin: getTrustedOrigins(),
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
@@ -167,17 +168,36 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Extract authentication token from Socket.IO handshake
|
||||
* @description Extract authentication token from Socket.IO handshake.
|
||||
*
|
||||
* Checks sources in order:
|
||||
* 1. handshake.auth.token — explicit token (e.g. from API clients)
|
||||
* 2. handshake.headers.cookie — session cookie sent by browser via withCredentials
|
||||
* 3. query.token — URL query parameter fallback
|
||||
* 4. Authorization header — Bearer token fallback
|
||||
*
|
||||
* @param client - The socket client
|
||||
* @returns The token string or undefined if not found
|
||||
*/
|
||||
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||
// Check handshake.auth.token (preferred method)
|
||||
// Check handshake.auth.token (preferred method for non-browser clients)
|
||||
const authToken = client.handshake.auth.token as unknown;
|
||||
if (typeof authToken === "string" && authToken.length > 0) {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
// Fallback: parse session cookie from request headers.
|
||||
// Browsers send httpOnly cookies automatically when withCredentials: true is set
|
||||
// on the socket.io client. BetterAuth uses one of these cookie names depending
|
||||
// on whether the connection is HTTPS (Secure prefix) or HTTP (dev).
|
||||
const cookieHeader = client.handshake.headers.cookie;
|
||||
if (typeof cookieHeader === "string" && cookieHeader.length > 0) {
|
||||
const cookieToken = this.extractTokenFromCookieHeader(cookieHeader);
|
||||
if (cookieToken) {
|
||||
return cookieToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check query parameters
|
||||
const queryToken = client.handshake.query.token as unknown;
|
||||
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||
@@ -197,6 +217,45 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Parse the BetterAuth session token from a raw Cookie header string.
|
||||
*
|
||||
* BetterAuth names the session cookie differently based on the security context:
|
||||
* - `__Secure-better-auth.session_token` — HTTPS with Secure flag
|
||||
* - `better-auth.session_token` — HTTP (development)
|
||||
* - `__Host-better-auth.session_token` — HTTPS with Host prefix
|
||||
*
|
||||
* @param cookieHeader - The raw Cookie header value
|
||||
* @returns The session token value or undefined if no matching cookie found
|
||||
*/
|
||||
private extractTokenFromCookieHeader(cookieHeader: string): string | undefined {
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
"__Secure-better-auth.session_token",
|
||||
"better-auth.session_token",
|
||||
"__Host-better-auth.session_token",
|
||||
] as const;
|
||||
|
||||
// Parse the Cookie header into a key-value map
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split(";").map((pair) => {
|
||||
const eqIndex = pair.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
return [pair.trim(), ""];
|
||||
}
|
||||
return [pair.slice(0, eqIndex).trim(), pair.slice(eqIndex + 1).trim()];
|
||||
})
|
||||
);
|
||||
|
||||
for (const name of SESSION_COOKIE_NAMES) {
|
||||
const value = cookies[name];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Handle client disconnect by leaving the workspace room.
|
||||
* @param client - The socket client containing workspaceId in data.
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
|
||||
import { WidgetsService } from "./widgets.service";
|
||||
import { WidgetDataService } from "./widget-data.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { RequestWithWorkspace } from "../common/types/user.types";
|
||||
|
||||
/**
|
||||
* Controller for widget definition and data endpoints
|
||||
* All endpoints require authentication
|
||||
* All endpoints require authentication; data endpoints also require workspace context
|
||||
*/
|
||||
@Controller("widgets")
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -51,12 +43,9 @@ export class WidgetsController {
|
||||
* Get stat card widget data
|
||||
*/
|
||||
@Post("data/stat-card")
|
||||
async getStatCardData(@Request() req: AuthenticatedRequest, @Body() query: StatCardQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getStatCardData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
|
||||
return this.widgetDataService.getStatCardData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,12 +53,9 @@ export class WidgetsController {
|
||||
* Get chart widget data
|
||||
*/
|
||||
@Post("data/chart")
|
||||
async getChartData(@Request() req: AuthenticatedRequest, @Body() query: ChartQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getChartData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
|
||||
return this.widgetDataService.getChartData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,12 +63,9 @@ export class WidgetsController {
|
||||
* Get list widget data
|
||||
*/
|
||||
@Post("data/list")
|
||||
async getListData(@Request() req: AuthenticatedRequest, @Body() query: ListQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getListData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
|
||||
return this.widgetDataService.getListData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,15 +73,12 @@ export class WidgetsController {
|
||||
* Get calendar preview widget data
|
||||
*/
|
||||
@Post("data/calendar-preview")
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getCalendarPreviewData(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Request() req: RequestWithWorkspace,
|
||||
@Body() query: CalendarPreviewQueryDto
|
||||
) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getCalendarPreviewData(workspaceId, query);
|
||||
return this.widgetDataService.getCalendarPreviewData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,12 +86,9 @@ export class WidgetsController {
|
||||
* Get active projects widget data
|
||||
*/
|
||||
@Post("data/active-projects")
|
||||
async getActiveProjectsData(@Request() req: AuthenticatedRequest) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getActiveProjectsData(workspaceId);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
|
||||
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,11 +96,8 @@ export class WidgetsController {
|
||||
* Get agent chains widget data (active agent sessions)
|
||||
*/
|
||||
@Post("data/agent-chains")
|
||||
async getAgentChainsData(@Request() req: AuthenticatedRequest) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getAgentChainsData(workspaceId);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
|
||||
return this.widgetDataService.getAgentChainsData(req.workspace.id);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user