Compare commits
46 Commits
feat/ms19-
...
feat/ms21-
| Author | SHA1 | Date | |
|---|---|---|---|
| de6aa9c768 | |||
| 85d3f930f3 | |||
| 0e6734bdae | |||
| 5bcaaeddd9 | |||
| 676a2a288b | |||
| ac16d6ed88 | |||
| 8388d49786 | |||
| 20f914ea85 | |||
| 1b84741f1a | |||
| ffc10c9a45 | |||
| 62d9ac0e5a | |||
| 8098504fb8 | |||
| 128431ba58 | |||
| d2c51eda91 | |||
| 78b643a945 | |||
| f93503ebcf | |||
| c0e679ab7c | |||
| 6ac63fe755 | |||
| 1667f28d71 | |||
| 66fe475fa1 | |||
| d39ab6aafc | |||
| 147e8ac574 | |||
| c38bfae16c | |||
| 36b4d8323d | |||
| 833662a64f | |||
| b3922e1d5b | |||
| 78b71a0ecc | |||
| dd0568cf15 | |||
| 8964226163 | |||
| 11f22a7e96 | |||
| edcff6a0e0 | |||
| e3cba37e8c | |||
| 21bf7e050f | |||
| 83d5aee53a | |||
| cc5b108b2f | |||
| 5ed0a859da | |||
| bf299bb672 | |||
| ad99cb9a03 | |||
| d05b870f08 | |||
| 1aaf5618ce | |||
| 9b2520ce1f | |||
| b110c469c4 | |||
| 859dcfc4b7 | |||
| 13aa52aa53 | |||
| 417c6ab49c | |||
| 8128eb7fbe |
30
.env.example
30
.env.example
@@ -79,7 +79,7 @@ OIDC_CLIENT_ID=your-client-id-here
|
|||||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||||
# Redirect URI must match what's configured in Authentik
|
# Redirect URI must match what's configured in Authentik
|
||||||
# Development: http://localhost:3001/auth/oauth2/callback/authentik
|
# Development: http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
# Production: https://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
|
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||||
|
|
||||||
# Authentik PostgreSQL Database
|
# Authentik PostgreSQL Database
|
||||||
@@ -314,17 +314,19 @@ COORDINATOR_ENABLED=true
|
|||||||
# TTL is in seconds, limits are per TTL window
|
# TTL is in seconds, limits are per TTL window
|
||||||
|
|
||||||
# Global rate limit (applies to all endpoints unless overridden)
|
# Global rate limit (applies to all endpoints unless overridden)
|
||||||
RATE_LIMIT_TTL=60 # Time window in seconds
|
# Time window in seconds
|
||||||
RATE_LIMIT_GLOBAL_LIMIT=100 # Requests per window
|
RATE_LIMIT_TTL=60
|
||||||
|
# Requests per window
|
||||||
|
RATE_LIMIT_GLOBAL_LIMIT=100
|
||||||
|
|
||||||
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch)
|
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute
|
||||||
RATE_LIMIT_WEBHOOK_LIMIT=60 # Requests per minute
|
RATE_LIMIT_WEBHOOK_LIMIT=60
|
||||||
|
|
||||||
# Coordinator endpoints (/coordinator/*)
|
# Coordinator endpoints (/coordinator/*) — requests per minute
|
||||||
RATE_LIMIT_COORDINATOR_LIMIT=100 # Requests per minute
|
RATE_LIMIT_COORDINATOR_LIMIT=100
|
||||||
|
|
||||||
# Health check endpoints (/coordinator/health)
|
# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring)
|
||||||
RATE_LIMIT_HEALTH_LIMIT=300 # Requests per minute (higher for monitoring)
|
RATE_LIMIT_HEALTH_LIMIT=300
|
||||||
|
|
||||||
# Storage backend for rate limiting (redis or memory)
|
# Storage backend for rate limiting (redis or memory)
|
||||||
# redis: Uses Valkey for distributed rate limiting (recommended for production)
|
# redis: Uses Valkey for distributed rate limiting (recommended for production)
|
||||||
@@ -359,17 +361,17 @@ RATE_LIMIT_STORAGE=redis
|
|||||||
# a single workspace.
|
# a single workspace.
|
||||||
MATRIX_HOMESERVER_URL=http://synapse:8008
|
MATRIX_HOMESERVER_URL=http://synapse:8008
|
||||||
MATRIX_ACCESS_TOKEN=
|
MATRIX_ACCESS_TOKEN=
|
||||||
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.example.com
|
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
|
||||||
MATRIX_SERVER_NAME=matrix.example.com
|
MATRIX_SERVER_NAME=matrix.woltje.com
|
||||||
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.example.com
|
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
|
||||||
# MATRIX_WORKSPACE_ID=your-workspace-uuid
|
# MATRIX_WORKSPACE_ID=your-workspace-uuid
|
||||||
|
|
||||||
# ======================
|
# ======================
|
||||||
# Matrix / Synapse Deployment
|
# Matrix / Synapse Deployment
|
||||||
# ======================
|
# ======================
|
||||||
# Domains for Traefik routing to Matrix services
|
# Domains for Traefik routing to Matrix services
|
||||||
MATRIX_DOMAIN=matrix.example.com
|
MATRIX_DOMAIN=matrix.woltje.com
|
||||||
ELEMENT_DOMAIN=chat.example.com
|
ELEMENT_DOMAIN=chat.woltje.com
|
||||||
|
|
||||||
# Synapse database (created automatically by synapse-db-init in the swarm compose)
|
# Synapse database (created automatically by synapse-db-init in the swarm compose)
|
||||||
SYNAPSE_POSTGRES_DB=synapse
|
SYNAPSE_POSTGRES_DB=synapse
|
||||||
|
|||||||
@@ -1,14 +1,80 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"mission_id": "prd-implementation-20260222",
|
"mission_id": "ms21-multi-tenant-rbac-data-migration-20260228",
|
||||||
"name": "PRD implementation",
|
"name": "MS21 Multi-Tenant RBAC Data Migration",
|
||||||
"description": "",
|
"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",
|
"project_path": "/home/jwoltje/src/mosaic-stack",
|
||||||
"created_at": "2026-02-23T03:20:55Z",
|
"created_at": "2026-02-28T17:10:22Z",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"task_prefix": "",
|
"task_prefix": "MS21",
|
||||||
"quality_gates": "",
|
"quality_gates": "pnpm lint && pnpm build && pnpm test",
|
||||||
"milestone_version": "0.0.1",
|
"milestone_version": "0.0.21",
|
||||||
"milestones": [],
|
"milestones": [
|
||||||
"sessions": []
|
{
|
||||||
|
"id": "phase-1",
|
||||||
|
"name": "Schema and Admin API",
|
||||||
|
"status": "pending",
|
||||||
|
"branch": "schema-and-admin-api",
|
||||||
|
"issue_ref": "",
|
||||||
|
"started_at": "",
|
||||||
|
"completed_at": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "phase-2",
|
||||||
|
"name": "Break-Glass Authentication",
|
||||||
|
"status": "pending",
|
||||||
|
"branch": "break-glass-authentication",
|
||||||
|
"issue_ref": "",
|
||||||
|
"started_at": "",
|
||||||
|
"completed_at": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "phase-3",
|
||||||
|
"name": "Data Migration",
|
||||||
|
"status": "pending",
|
||||||
|
"branch": "data-migration",
|
||||||
|
"issue_ref": "",
|
||||||
|
"started_at": "",
|
||||||
|
"completed_at": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "phase-4",
|
||||||
|
"name": "Admin UI",
|
||||||
|
"status": "pending",
|
||||||
|
"branch": "admin-ui",
|
||||||
|
"issue_ref": "",
|
||||||
|
"started_at": "",
|
||||||
|
"completed_at": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "phase-5",
|
||||||
|
"name": "RBAC UI Enforcement",
|
||||||
|
"status": "pending",
|
||||||
|
"branch": "rbac-ui-enforcement",
|
||||||
|
"issue_ref": "",
|
||||||
|
"started_at": "",
|
||||||
|
"completed_at": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "phase-6",
|
||||||
|
"name": "Verification",
|
||||||
|
"status": "pending",
|
||||||
|
"branch": "verification",
|
||||||
|
"issue_ref": "",
|
||||||
|
"started_at": "",
|
||||||
|
"completed_at": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"sessions": [
|
||||||
|
{
|
||||||
|
"session_id": "sess-001",
|
||||||
|
"runtime": "unknown",
|
||||||
|
"started_at": "2026-02-28T17:48:51Z",
|
||||||
|
"ended_at": "",
|
||||||
|
"ended_reason": "",
|
||||||
|
"milestone_at_end": "",
|
||||||
|
"tasks_completed": [],
|
||||||
|
"last_task_id": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
8
.mosaic/orchestrator/session.lock
Normal file
8
.mosaic/orchestrator/session.lock
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"session_id": "sess-001",
|
||||||
|
"runtime": "unknown",
|
||||||
|
"pid": 2396592,
|
||||||
|
"started_at": "2026-02-28T17:48:51Z",
|
||||||
|
"project_path": "/tmp/ms21-api-003",
|
||||||
|
"milestone_id": ""
|
||||||
|
}
|
||||||
@@ -24,6 +24,13 @@ variables:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
- &use_deps |
|
- &use_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
|
- &turbo_env
|
||||||
|
TURBO_API:
|
||||||
|
from_secret: turbo_api
|
||||||
|
TURBO_TOKEN:
|
||||||
|
from_secret: turbo_token
|
||||||
|
TURBO_TEAM:
|
||||||
|
from_secret: turbo_team
|
||||||
- &kaniko_setup |
|
- &kaniko_setup |
|
||||||
mkdir -p /kaniko/.docker
|
mkdir -p /kaniko/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||||
@@ -52,17 +59,6 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
lint:
|
|
||||||
image: *node_image
|
|
||||||
environment:
|
|
||||||
SKIP_ENV_VALIDATION: "true"
|
|
||||||
commands:
|
|
||||||
- *use_deps
|
|
||||||
- pnpm --filter "@mosaic/api" lint
|
|
||||||
depends_on:
|
|
||||||
- prisma-generate
|
|
||||||
- build-shared
|
|
||||||
|
|
||||||
prisma-generate:
|
prisma-generate:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
@@ -73,26 +69,27 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
build-shared:
|
lint:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm --filter "@mosaic/shared" build
|
- pnpm turbo lint --filter=@mosaic/api
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- prisma-generate
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm --filter "@mosaic/api" typecheck
|
- pnpm turbo typecheck --filter=@mosaic/api
|
||||||
depends_on:
|
depends_on:
|
||||||
- prisma-generate
|
- prisma-generate
|
||||||
- build-shared
|
|
||||||
|
|
||||||
prisma-migrate:
|
prisma-migrate:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
@@ -124,6 +121,7 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo build --filter=@mosaic/api
|
- pnpm turbo build --filter=@mosaic/api
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ variables:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
- &use_deps |
|
- &use_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
|
- &turbo_env
|
||||||
|
TURBO_API:
|
||||||
|
from_secret: turbo_api
|
||||||
|
TURBO_TOKEN:
|
||||||
|
from_secret: turbo_token
|
||||||
|
TURBO_TEAM:
|
||||||
|
from_secret: turbo_team
|
||||||
- &kaniko_setup |
|
- &kaniko_setup |
|
||||||
mkdir -p /kaniko/.docker
|
mkdir -p /kaniko/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||||
@@ -48,9 +55,10 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm --filter "@mosaic/orchestrator" lint
|
- pnpm turbo lint --filter=@mosaic/orchestrator
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
@@ -58,9 +66,10 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm --filter "@mosaic/orchestrator" typecheck
|
- pnpm turbo typecheck --filter=@mosaic/orchestrator
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
@@ -68,9 +77,10 @@ steps:
|
|||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm --filter "@mosaic/orchestrator" test
|
- pnpm turbo test --filter=@mosaic/orchestrator
|
||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
@@ -81,6 +91,7 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo build --filter=@mosaic/orchestrator
|
- pnpm turbo build --filter=@mosaic/orchestrator
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ variables:
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
- &use_deps |
|
- &use_deps |
|
||||||
corepack enable
|
corepack enable
|
||||||
|
- &turbo_env
|
||||||
|
TURBO_API:
|
||||||
|
from_secret: turbo_api
|
||||||
|
TURBO_TOKEN:
|
||||||
|
from_secret: turbo_token
|
||||||
|
TURBO_TEAM:
|
||||||
|
from_secret: turbo_team
|
||||||
- &kaniko_setup |
|
- &kaniko_setup |
|
||||||
mkdir -p /kaniko/.docker
|
mkdir -p /kaniko/.docker
|
||||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||||
@@ -44,46 +51,38 @@ steps:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- install
|
- install
|
||||||
|
|
||||||
build-shared:
|
|
||||||
image: *node_image
|
|
||||||
environment:
|
|
||||||
SKIP_ENV_VALIDATION: "true"
|
|
||||||
commands:
|
|
||||||
- *use_deps
|
|
||||||
- pnpm --filter "@mosaic/shared" build
|
|
||||||
- pnpm --filter "@mosaic/ui" build
|
|
||||||
depends_on:
|
|
||||||
- install
|
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm --filter "@mosaic/web" lint
|
- pnpm turbo lint --filter=@mosaic/web
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-shared
|
- install
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm --filter "@mosaic/web" typecheck
|
- pnpm turbo typecheck --filter=@mosaic/web
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-shared
|
- install
|
||||||
|
|
||||||
test:
|
test:
|
||||||
image: *node_image
|
image: *node_image
|
||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm --filter "@mosaic/web" test
|
- pnpm turbo test --filter=@mosaic/web
|
||||||
depends_on:
|
depends_on:
|
||||||
- build-shared
|
- install
|
||||||
|
|
||||||
# === Build ===
|
# === Build ===
|
||||||
|
|
||||||
@@ -92,6 +91,7 @@ steps:
|
|||||||
environment:
|
environment:
|
||||||
SKIP_ENV_VALIDATION: "true"
|
SKIP_ENV_VALIDATION: "true"
|
||||||
NODE_ENV: "production"
|
NODE_ENV: "production"
|
||||||
|
<<: *turbo_env
|
||||||
commands:
|
commands:
|
||||||
- *use_deps
|
- *use_deps
|
||||||
- pnpm turbo build --filter=@mosaic/web
|
- pnpm turbo build --filter=@mosaic/web
|
||||||
|
|||||||
15
AGENTS.md
15
AGENTS.md
@@ -46,6 +46,21 @@ pnpm lint
|
|||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Versioning Protocol (HARD GATE)
|
||||||
|
|
||||||
|
**This project is ALPHA. All versions MUST be `0.0.x`.**
|
||||||
|
|
||||||
|
- The `0.1.0` release is FORBIDDEN until Jason explicitly authorizes it.
|
||||||
|
- Every milestone bump increments the patch: `0.0.20` → `0.0.21` → `0.0.22`, etc.
|
||||||
|
- ALL package.json files in the monorepo MUST stay in sync at the same version.
|
||||||
|
- Use `scripts/version-bump.sh <version>` to bump — it enforces the alpha constraint and updates all packages atomically.
|
||||||
|
- The script rejects any version >= `0.1.0`.
|
||||||
|
- When creating a release tag, the tag MUST match the package version: `v0.0.x`.
|
||||||
|
|
||||||
|
**Milestone-to-version mapping** is defined in the PRD (`docs/PRD.md`) under "Delivery/Milestone Intent". Agents MUST use the version from that table when tagging a milestone release.
|
||||||
|
|
||||||
|
**Violation of this protocol is a blocking error.** If an agent attempts to set a version >= `0.1.0`, stop and escalate.
|
||||||
|
|
||||||
## Standards and Quality
|
## Standards and Quality
|
||||||
|
|
||||||
- Enforce strict typing and no unsafe shortcuts.
|
- Enforce strict typing and no unsafe shortcuts.
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ COPY turbo.json ./
|
|||||||
# ======================
|
# ======================
|
||||||
FROM base AS deps
|
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 all package.json files for workspace resolution
|
||||||
COPY packages/shared/package.json ./packages/shared/
|
COPY packages/shared/package.json ./packages/shared/
|
||||||
COPY packages/ui/package.json ./packages/ui/
|
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/
|
COPY apps/api/package.json ./apps/api/
|
||||||
|
|
||||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
# 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
|
# 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
|
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)
|
# 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 \
|
&& chmod 755 /usr/local/bin/dumb-init \
|
||||||
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
&& groupadd -g 1001 nodejs && useradd -m -u 1001 -g nodejs nestjs
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/api",
|
"name": "@mosaic/api",
|
||||||
"version": "0.0.1",
|
"version": "0.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "nest build",
|
"build": "nest build",
|
||||||
@@ -52,6 +52,7 @@
|
|||||||
"adm-zip": "^0.5.16",
|
"adm-zip": "^0.5.16",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"bullmq": "^5.67.2",
|
"bullmq": "^5.67.2",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
@@ -85,6 +86,7 @@
|
|||||||
"@swc/core": "^1.10.18",
|
"@swc/core": "^1.10.18",
|
||||||
"@types/adm-zip": "^0.5.7",
|
"@types/adm-zip": "^0.5.7",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cookie-parser": "^1.4.10",
|
||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"@types/highlight.js": "^10.1.0",
|
"@types/highlight.js": "^10.1.0",
|
||||||
|
|||||||
@@ -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 {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||||
previewFeatures = ["postgresqlExtensions"]
|
previewFeatures = ["postgresqlExtensions"]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,6 +227,14 @@ model User {
|
|||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
|
||||||
|
|
||||||
|
// MS21: Admin, local auth, and invitation fields
|
||||||
|
deactivatedAt DateTime? @map("deactivated_at") @db.Timestamptz
|
||||||
|
isLocalAuth Boolean @default(false) @map("is_local_auth")
|
||||||
|
passwordHash String? @map("password_hash")
|
||||||
|
invitedBy String? @map("invited_by") @db.Uuid
|
||||||
|
invitationToken String? @unique @map("invitation_token")
|
||||||
|
invitedAt DateTime? @map("invited_at") @db.Timestamptz
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
|
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
|
||||||
workspaceMemberships WorkspaceMember[]
|
workspaceMemberships WorkspaceMember[]
|
||||||
@@ -1067,6 +1076,10 @@ model Personality {
|
|||||||
displayName String @map("display_name")
|
displayName String @map("display_name")
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
|
|
||||||
|
// Tone and formality
|
||||||
|
tone String @default("neutral")
|
||||||
|
formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
|
||||||
|
|
||||||
// System prompt
|
// System prompt
|
||||||
systemPrompt String @map("system_prompt") @db.Text
|
systemPrompt String @map("system_prompt") @db.Text
|
||||||
|
|
||||||
|
|||||||
258
apps/api/src/admin/admin.controller.spec.ts
Normal file
258
apps/api/src/admin/admin.controller.spec.ts
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AdminController } from "./admin.controller";
|
||||||
|
import { AdminService } from "./admin.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||||
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import type { ExecutionContext } from "@nestjs/common";
|
||||||
|
|
||||||
|
describe("AdminController", () => {
|
||||||
|
let controller: AdminController;
|
||||||
|
let service: AdminService;
|
||||||
|
|
||||||
|
const mockAdminService = {
|
||||||
|
listUsers: vi.fn(),
|
||||||
|
inviteUser: vi.fn(),
|
||||||
|
updateUser: vi.fn(),
|
||||||
|
deactivateUser: vi.fn(),
|
||||||
|
createWorkspace: vi.fn(),
|
||||||
|
updateWorkspace: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAuthGuard = {
|
||||||
|
canActivate: vi.fn((context: ExecutionContext) => {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
request.user = {
|
||||||
|
id: "550e8400-e29b-41d4-a716-446655440001",
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Admin User",
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAdminGuard = {
|
||||||
|
canActivate: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
|
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
|
||||||
|
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003";
|
||||||
|
|
||||||
|
const mockAdminUser = {
|
||||||
|
id: mockAdminId,
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Admin User",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUserResponse = {
|
||||||
|
id: mockUserId,
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
emailVerified: false,
|
||||||
|
image: null,
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
deactivatedAt: null,
|
||||||
|
isLocalAuth: false,
|
||||||
|
invitedAt: null,
|
||||||
|
invitedBy: null,
|
||||||
|
workspaceMemberships: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkspaceResponse = {
|
||||||
|
id: mockWorkspaceId,
|
||||||
|
name: "Test Workspace",
|
||||||
|
ownerId: mockAdminId,
|
||||||
|
settings: {},
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
updatedAt: new Date("2026-01-01"),
|
||||||
|
memberCount: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [AdminController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: AdminService,
|
||||||
|
useValue: mockAdminService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue(mockAuthGuard)
|
||||||
|
.overrideGuard(AdminGuard)
|
||||||
|
.useValue(mockAdminGuard)
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<AdminController>(AdminController);
|
||||||
|
service = module.get<AdminService>(AdminService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listUsers", () => {
|
||||||
|
it("should return paginated users", async () => {
|
||||||
|
const paginatedResult = {
|
||||||
|
data: [mockUserResponse],
|
||||||
|
meta: { total: 1, page: 1, limit: 50, totalPages: 1 },
|
||||||
|
};
|
||||||
|
mockAdminService.listUsers.mockResolvedValue(paginatedResult);
|
||||||
|
|
||||||
|
const result = await controller.listUsers({ page: 1, limit: 50 });
|
||||||
|
|
||||||
|
expect(result).toEqual(paginatedResult);
|
||||||
|
expect(service.listUsers).toHaveBeenCalledWith(1, 50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default pagination", async () => {
|
||||||
|
const paginatedResult = {
|
||||||
|
data: [],
|
||||||
|
meta: { total: 0, page: 1, limit: 50, totalPages: 0 },
|
||||||
|
};
|
||||||
|
mockAdminService.listUsers.mockResolvedValue(paginatedResult);
|
||||||
|
|
||||||
|
await controller.listUsers({});
|
||||||
|
|
||||||
|
expect(service.listUsers).toHaveBeenCalledWith(undefined, undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inviteUser", () => {
|
||||||
|
it("should invite a user", async () => {
|
||||||
|
const inviteDto = { email: "new@example.com" };
|
||||||
|
const invitationResponse = {
|
||||||
|
userId: "new-id",
|
||||||
|
invitationToken: "token",
|
||||||
|
email: "new@example.com",
|
||||||
|
invitedAt: new Date(),
|
||||||
|
};
|
||||||
|
mockAdminService.inviteUser.mockResolvedValue(invitationResponse);
|
||||||
|
|
||||||
|
const result = await controller.inviteUser(inviteDto, mockAdminUser);
|
||||||
|
|
||||||
|
expect(result).toEqual(invitationResponse);
|
||||||
|
expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should invite a user with workspace and role", async () => {
|
||||||
|
const inviteDto = {
|
||||||
|
email: "new@example.com",
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
};
|
||||||
|
mockAdminService.inviteUser.mockResolvedValue({
|
||||||
|
userId: "new-id",
|
||||||
|
invitationToken: "token",
|
||||||
|
email: "new@example.com",
|
||||||
|
invitedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await controller.inviteUser(inviteDto, mockAdminUser);
|
||||||
|
|
||||||
|
expect(service.inviteUser).toHaveBeenCalledWith(inviteDto, mockAdminId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateUser", () => {
|
||||||
|
it("should update a user", async () => {
|
||||||
|
const updateDto = { name: "Updated Name" };
|
||||||
|
mockAdminService.updateUser.mockResolvedValue({
|
||||||
|
...mockUserResponse,
|
||||||
|
name: "Updated Name",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.updateUser(mockUserId, updateDto);
|
||||||
|
|
||||||
|
expect(result.name).toBe("Updated Name");
|
||||||
|
expect(service.updateUser).toHaveBeenCalledWith(mockUserId, updateDto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should deactivate a user via update", async () => {
|
||||||
|
const deactivatedAt = "2026-02-28T00:00:00.000Z";
|
||||||
|
const updateDto = { deactivatedAt };
|
||||||
|
mockAdminService.updateUser.mockResolvedValue({
|
||||||
|
...mockUserResponse,
|
||||||
|
deactivatedAt: new Date(deactivatedAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.updateUser(mockUserId, updateDto);
|
||||||
|
|
||||||
|
expect(result.deactivatedAt).toEqual(new Date(deactivatedAt));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deactivateUser", () => {
|
||||||
|
it("should soft-delete a user", async () => {
|
||||||
|
mockAdminService.deactivateUser.mockResolvedValue({
|
||||||
|
...mockUserResponse,
|
||||||
|
deactivatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.deactivateUser(mockUserId);
|
||||||
|
|
||||||
|
expect(result.deactivatedAt).toBeDefined();
|
||||||
|
expect(service.deactivateUser).toHaveBeenCalledWith(mockUserId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createWorkspace", () => {
|
||||||
|
it("should create a workspace", async () => {
|
||||||
|
const createDto = { name: "New Workspace", ownerId: mockAdminId };
|
||||||
|
mockAdminService.createWorkspace.mockResolvedValue(mockWorkspaceResponse);
|
||||||
|
|
||||||
|
const result = await controller.createWorkspace(createDto);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockWorkspaceResponse);
|
||||||
|
expect(service.createWorkspace).toHaveBeenCalledWith(createDto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create workspace with settings", async () => {
|
||||||
|
const createDto = {
|
||||||
|
name: "New Workspace",
|
||||||
|
ownerId: mockAdminId,
|
||||||
|
settings: { feature: true },
|
||||||
|
};
|
||||||
|
mockAdminService.createWorkspace.mockResolvedValue({
|
||||||
|
...mockWorkspaceResponse,
|
||||||
|
settings: { feature: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.createWorkspace(createDto);
|
||||||
|
|
||||||
|
expect(result.settings).toEqual({ feature: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateWorkspace", () => {
|
||||||
|
it("should update a workspace", async () => {
|
||||||
|
const updateDto = { name: "Updated Workspace" };
|
||||||
|
mockAdminService.updateWorkspace.mockResolvedValue({
|
||||||
|
...mockWorkspaceResponse,
|
||||||
|
name: "Updated Workspace",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.updateWorkspace(mockWorkspaceId, updateDto);
|
||||||
|
|
||||||
|
expect(result.name).toBe("Updated Workspace");
|
||||||
|
expect(service.updateWorkspace).toHaveBeenCalledWith(mockWorkspaceId, updateDto);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update workspace settings", async () => {
|
||||||
|
const updateDto = { settings: { notifications: false } };
|
||||||
|
mockAdminService.updateWorkspace.mockResolvedValue({
|
||||||
|
...mockWorkspaceResponse,
|
||||||
|
settings: { notifications: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await controller.updateWorkspace(mockWorkspaceId, updateDto);
|
||||||
|
|
||||||
|
expect(result.settings).toEqual({ notifications: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
64
apps/api/src/admin/admin.controller.ts
Normal file
64
apps/api/src/admin/admin.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Patch,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { AdminService } from "./admin.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { AdminGuard } from "../auth/guards/admin.guard";
|
||||||
|
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||||
|
import type { AuthUser } from "@mosaic/shared";
|
||||||
|
import { InviteUserDto } from "./dto/invite-user.dto";
|
||||||
|
import { UpdateUserDto } from "./dto/update-user.dto";
|
||||||
|
import { CreateWorkspaceDto } from "./dto/create-workspace.dto";
|
||||||
|
import { UpdateWorkspaceDto } from "./dto/update-workspace.dto";
|
||||||
|
import { QueryUsersDto } from "./dto/query-users.dto";
|
||||||
|
|
||||||
|
@Controller("admin")
|
||||||
|
@UseGuards(AuthGuard, AdminGuard)
|
||||||
|
export class AdminController {
|
||||||
|
constructor(private readonly adminService: AdminService) {}
|
||||||
|
|
||||||
|
@Get("users")
|
||||||
|
async listUsers(@Query() query: QueryUsersDto) {
|
||||||
|
return this.adminService.listUsers(query.page, query.limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("users/invite")
|
||||||
|
async inviteUser(@Body() dto: InviteUserDto, @CurrentUser() user: AuthUser) {
|
||||||
|
return this.adminService.inviteUser(dto, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("users/:id")
|
||||||
|
async updateUser(
|
||||||
|
@Param("id", new ParseUUIDPipe({ version: "4" })) id: string,
|
||||||
|
@Body() dto: UpdateUserDto
|
||||||
|
) {
|
||||||
|
return this.adminService.updateUser(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete("users/:id")
|
||||||
|
async deactivateUser(@Param("id", new ParseUUIDPipe({ version: "4" })) id: string) {
|
||||||
|
return this.adminService.deactivateUser(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("workspaces")
|
||||||
|
async createWorkspace(@Body() dto: CreateWorkspaceDto) {
|
||||||
|
return this.adminService.createWorkspace(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch("workspaces/:id")
|
||||||
|
async updateWorkspace(
|
||||||
|
@Param("id", new ParseUUIDPipe({ version: "4" })) id: string,
|
||||||
|
@Body() dto: UpdateWorkspaceDto
|
||||||
|
) {
|
||||||
|
return this.adminService.updateWorkspace(id, dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/admin/admin.module.ts
Normal file
13
apps/api/src/admin/admin.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AdminController } from "./admin.controller";
|
||||||
|
import { AdminService } from "./admin.service";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [AdminController],
|
||||||
|
providers: [AdminService],
|
||||||
|
exports: [AdminService],
|
||||||
|
})
|
||||||
|
export class AdminModule {}
|
||||||
471
apps/api/src/admin/admin.service.spec.ts
Normal file
471
apps/api/src/admin/admin.service.spec.ts
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { AdminService } from "./admin.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
|
||||||
|
describe("AdminService", () => {
|
||||||
|
let service: AdminService;
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
user: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
},
|
||||||
|
workspaceMember: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
|
const mockUserId = "550e8400-e29b-41d4-a716-446655440002";
|
||||||
|
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440003";
|
||||||
|
|
||||||
|
const mockUser = {
|
||||||
|
id: mockUserId,
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
emailVerified: false,
|
||||||
|
image: null,
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
updatedAt: new Date("2026-01-01"),
|
||||||
|
deactivatedAt: null,
|
||||||
|
isLocalAuth: false,
|
||||||
|
passwordHash: null,
|
||||||
|
invitedBy: null,
|
||||||
|
invitationToken: null,
|
||||||
|
invitedAt: null,
|
||||||
|
authProviderId: null,
|
||||||
|
preferences: {},
|
||||||
|
workspaceMemberships: [
|
||||||
|
{
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
workspace: { id: mockWorkspaceId, name: "Test Workspace" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkspace = {
|
||||||
|
id: mockWorkspaceId,
|
||||||
|
name: "Test Workspace",
|
||||||
|
ownerId: mockAdminId,
|
||||||
|
settings: {},
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
updatedAt: new Date("2026-01-01"),
|
||||||
|
matrixRoomId: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
AdminService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<AdminService>(AdminService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockPrismaService.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => {
|
||||||
|
return fn(mockPrismaService);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("listUsers", () => {
|
||||||
|
it("should return paginated users with memberships", async () => {
|
||||||
|
mockPrismaService.user.findMany.mockResolvedValue([mockUser]);
|
||||||
|
mockPrismaService.user.count.mockResolvedValue(1);
|
||||||
|
|
||||||
|
const result = await service.listUsers(1, 50);
|
||||||
|
|
||||||
|
expect(result.data).toHaveLength(1);
|
||||||
|
expect(result.data[0]?.id).toBe(mockUserId);
|
||||||
|
expect(result.data[0]?.workspaceMemberships).toHaveLength(1);
|
||||||
|
expect(result.meta).toEqual({
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
limit: 50,
|
||||||
|
totalPages: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use default pagination when not provided", async () => {
|
||||||
|
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||||
|
mockPrismaService.user.count.mockResolvedValue(0);
|
||||||
|
|
||||||
|
await service.listUsers();
|
||||||
|
|
||||||
|
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
skip: 0,
|
||||||
|
take: 50,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should calculate pagination correctly", async () => {
|
||||||
|
mockPrismaService.user.findMany.mockResolvedValue([]);
|
||||||
|
mockPrismaService.user.count.mockResolvedValue(150);
|
||||||
|
|
||||||
|
const result = await service.listUsers(3, 25);
|
||||||
|
|
||||||
|
expect(mockPrismaService.user.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
skip: 50,
|
||||||
|
take: 25,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.meta.totalPages).toBe(6);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inviteUser", () => {
|
||||||
|
it("should create a user with invitation token", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
const createdUser = {
|
||||||
|
id: "new-user-id",
|
||||||
|
email: "new@example.com",
|
||||||
|
name: "new",
|
||||||
|
invitationToken: "some-token",
|
||||||
|
};
|
||||||
|
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||||
|
|
||||||
|
const result = await service.inviteUser({ email: "new@example.com" }, mockAdminId);
|
||||||
|
|
||||||
|
expect(result.email).toBe("new@example.com");
|
||||||
|
expect(result.invitationToken).toBeDefined();
|
||||||
|
expect(result.userId).toBe("new-user-id");
|
||||||
|
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
email: "new@example.com",
|
||||||
|
invitedBy: mockAdminId,
|
||||||
|
invitationToken: expect.any(String),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add user to workspace when workspaceId provided", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||||
|
const createdUser = { id: "new-user-id", email: "new@example.com", name: "new" };
|
||||||
|
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||||
|
|
||||||
|
await service.inviteUser(
|
||||||
|
{
|
||||||
|
email: "new@example.com",
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
},
|
||||||
|
mockAdminId
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: "new-user-id",
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ConflictException if email already exists", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
|
|
||||||
|
await expect(service.inviteUser({ email: "test@example.com" }, mockAdminId)).rejects.toThrow(
|
||||||
|
ConflictException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if workspace does not exist", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.inviteUser({ email: "new@example.com", workspaceId: "non-existent" }, mockAdminId)
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use email prefix as default name", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
const createdUser = { id: "new-user-id", email: "jane.doe@example.com", name: "jane.doe" };
|
||||||
|
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||||
|
|
||||||
|
await service.inviteUser({ email: "jane.doe@example.com" }, mockAdminId);
|
||||||
|
|
||||||
|
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
name: "jane.doe",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use provided name when given", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
const createdUser = { id: "new-user-id", email: "j@example.com", name: "Jane Doe" };
|
||||||
|
mockPrismaService.user.create.mockResolvedValue(createdUser);
|
||||||
|
|
||||||
|
await service.inviteUser({ email: "j@example.com", name: "Jane Doe" }, mockAdminId);
|
||||||
|
|
||||||
|
expect(mockPrismaService.user.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
name: "Jane Doe",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateUser", () => {
|
||||||
|
it("should update user fields", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
|
mockPrismaService.user.update.mockResolvedValue({
|
||||||
|
...mockUser,
|
||||||
|
name: "Updated Name",
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.updateUser(mockUserId, { name: "Updated Name" });
|
||||||
|
|
||||||
|
expect(result.name).toBe("Updated Name");
|
||||||
|
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: mockUserId },
|
||||||
|
data: { name: "Updated Name" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set deactivatedAt when provided", async () => {
|
||||||
|
const deactivatedAt = "2026-02-28T00:00:00.000Z";
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
|
mockPrismaService.user.update.mockResolvedValue({
|
||||||
|
...mockUser,
|
||||||
|
deactivatedAt: new Date(deactivatedAt),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.updateUser(mockUserId, { deactivatedAt });
|
||||||
|
|
||||||
|
expect(result.deactivatedAt).toEqual(new Date(deactivatedAt));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear deactivatedAt when set to null", async () => {
|
||||||
|
const deactivatedUser = { ...mockUser, deactivatedAt: new Date() };
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(deactivatedUser);
|
||||||
|
mockPrismaService.user.update.mockResolvedValue({
|
||||||
|
...deactivatedUser,
|
||||||
|
deactivatedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.updateUser(mockUserId, { deactivatedAt: null });
|
||||||
|
|
||||||
|
expect(result.deactivatedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if user does not exist", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.updateUser("non-existent", { name: "Test" })).rejects.toThrow(
|
||||||
|
NotFoundException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update emailVerified", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
|
mockPrismaService.user.update.mockResolvedValue({
|
||||||
|
...mockUser,
|
||||||
|
emailVerified: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.updateUser(mockUserId, { emailVerified: true });
|
||||||
|
|
||||||
|
expect(result.emailVerified).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update preferences", async () => {
|
||||||
|
const prefs = { theme: "dark" };
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
|
mockPrismaService.user.update.mockResolvedValue({
|
||||||
|
...mockUser,
|
||||||
|
preferences: prefs,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateUser(mockUserId, { preferences: prefs });
|
||||||
|
|
||||||
|
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ preferences: prefs }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deactivateUser", () => {
|
||||||
|
it("should set deactivatedAt on the user", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
|
mockPrismaService.user.update.mockResolvedValue({
|
||||||
|
...mockUser,
|
||||||
|
deactivatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.deactivateUser(mockUserId);
|
||||||
|
|
||||||
|
expect(result.deactivatedAt).toBeDefined();
|
||||||
|
expect(mockPrismaService.user.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { id: mockUserId },
|
||||||
|
data: { deactivatedAt: expect.any(Date) },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if user does not exist", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.deactivateUser("non-existent")).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException if user is already deactivated", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||||
|
...mockUser,
|
||||||
|
deactivatedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.deactivateUser(mockUserId)).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createWorkspace", () => {
|
||||||
|
it("should create a workspace with owner membership", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
|
mockPrismaService.workspace.create.mockResolvedValue(mockWorkspace);
|
||||||
|
|
||||||
|
const result = await service.createWorkspace({
|
||||||
|
name: "New Workspace",
|
||||||
|
ownerId: mockAdminId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.name).toBe("Test Workspace");
|
||||||
|
expect(result.memberCount).toBe(1);
|
||||||
|
expect(mockPrismaService.workspace.create).toHaveBeenCalled();
|
||||||
|
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
workspaceId: mockWorkspace.id,
|
||||||
|
userId: mockAdminId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if owner does not exist", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createWorkspace({ name: "New Workspace", ownerId: "non-existent" })
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should pass settings when provided", async () => {
|
||||||
|
const settings = { theme: "dark", features: ["chat"] };
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
|
||||||
|
mockPrismaService.workspace.create.mockResolvedValue({
|
||||||
|
...mockWorkspace,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.createWorkspace({
|
||||||
|
name: "New Workspace",
|
||||||
|
ownerId: mockAdminId,
|
||||||
|
settings,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPrismaService.workspace.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ settings }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateWorkspace", () => {
|
||||||
|
it("should update workspace name", async () => {
|
||||||
|
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||||
|
mockPrismaService.workspace.update.mockResolvedValue({
|
||||||
|
...mockWorkspace,
|
||||||
|
name: "Updated Workspace",
|
||||||
|
_count: { members: 3 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.updateWorkspace(mockWorkspaceId, {
|
||||||
|
name: "Updated Workspace",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.name).toBe("Updated Workspace");
|
||||||
|
expect(result.memberCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update workspace settings", async () => {
|
||||||
|
const newSettings = { notifications: true };
|
||||||
|
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||||
|
mockPrismaService.workspace.update.mockResolvedValue({
|
||||||
|
...mockWorkspace,
|
||||||
|
settings: newSettings,
|
||||||
|
_count: { members: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.updateWorkspace(mockWorkspaceId, {
|
||||||
|
settings: newSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.settings).toEqual(newSettings);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException if workspace does not exist", async () => {
|
||||||
|
mockPrismaService.workspace.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.updateWorkspace("non-existent", { name: "Test" })).rejects.toThrow(
|
||||||
|
NotFoundException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should only update provided fields", async () => {
|
||||||
|
mockPrismaService.workspace.findUnique.mockResolvedValue(mockWorkspace);
|
||||||
|
mockPrismaService.workspace.update.mockResolvedValue({
|
||||||
|
...mockWorkspace,
|
||||||
|
_count: { members: 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.updateWorkspace(mockWorkspaceId, { name: "Only Name" });
|
||||||
|
|
||||||
|
expect(mockPrismaService.workspace.update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: { name: "Only Name" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
306
apps/api/src/admin/admin.service.ts
Normal file
306
apps/api/src/admin/admin.service.ts
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Prisma, WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { InviteUserDto } from "./dto/invite-user.dto";
|
||||||
|
import type { UpdateUserDto } from "./dto/update-user.dto";
|
||||||
|
import type { CreateWorkspaceDto } from "./dto/create-workspace.dto";
|
||||||
|
import type {
|
||||||
|
AdminUserResponse,
|
||||||
|
AdminWorkspaceResponse,
|
||||||
|
InvitationResponse,
|
||||||
|
PaginatedResponse,
|
||||||
|
} from "./types/admin.types";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AdminService {
|
||||||
|
private readonly logger = new Logger(AdminService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async listUsers(page = 1, limit = 50): Promise<PaginatedResponse<AdminUserResponse>> {
|
||||||
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
|
const [users, total] = await Promise.all([
|
||||||
|
this.prisma.user.findMany({
|
||||||
|
include: {
|
||||||
|
workspaceMemberships: {
|
||||||
|
include: {
|
||||||
|
workspace: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
skip,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.prisma.user.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: users.map((user) => ({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
image: user.image,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
deactivatedAt: user.deactivatedAt,
|
||||||
|
isLocalAuth: user.isLocalAuth,
|
||||||
|
invitedAt: user.invitedAt,
|
||||||
|
invitedBy: user.invitedBy,
|
||||||
|
workspaceMemberships: user.workspaceMemberships.map((m) => ({
|
||||||
|
workspaceId: m.workspaceId,
|
||||||
|
workspaceName: m.workspace.name,
|
||||||
|
role: m.role,
|
||||||
|
joinedAt: m.joinedAt,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
meta: {
|
||||||
|
total,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async inviteUser(dto: InviteUserDto, inviterId: string): Promise<InvitationResponse> {
|
||||||
|
const existing = await this.prisma.user.findUnique({
|
||||||
|
where: { email: dto.email },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException(`User with email ${dto.email} already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.workspaceId) {
|
||||||
|
const workspace = await this.prisma.workspace.findUnique({
|
||||||
|
where: { id: dto.workspaceId },
|
||||||
|
});
|
||||||
|
if (!workspace) {
|
||||||
|
throw new NotFoundException(`Workspace ${dto.workspaceId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const invitationToken = randomUUID();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
const user = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
email: dto.email,
|
||||||
|
name: dto.name ?? dto.email.split("@")[0] ?? dto.email,
|
||||||
|
emailVerified: false,
|
||||||
|
invitedBy: inviterId,
|
||||||
|
invitationToken,
|
||||||
|
invitedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dto.workspaceId) {
|
||||||
|
await tx.workspaceMember.create({
|
||||||
|
data: {
|
||||||
|
workspaceId: dto.workspaceId,
|
||||||
|
userId: created.id,
|
||||||
|
role: dto.role ?? WorkspaceMemberRole.MEMBER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`User invited: ${user.email} by ${inviterId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
invitationToken,
|
||||||
|
email: user.email,
|
||||||
|
invitedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(id: string, dto: UpdateUserDto): Promise<AdminUserResponse> {
|
||||||
|
const existing = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(`User ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Prisma.UserUpdateInput = {};
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
data.name = dto.name;
|
||||||
|
}
|
||||||
|
if (dto.emailVerified !== undefined) {
|
||||||
|
data.emailVerified = dto.emailVerified;
|
||||||
|
}
|
||||||
|
if (dto.preferences !== undefined) {
|
||||||
|
data.preferences = dto.preferences as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
if (dto.deactivatedAt !== undefined) {
|
||||||
|
data.deactivatedAt = dto.deactivatedAt ? new Date(dto.deactivatedAt) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
workspaceMemberships: {
|
||||||
|
include: {
|
||||||
|
workspace: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`User updated: ${id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
image: user.image,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
deactivatedAt: user.deactivatedAt,
|
||||||
|
isLocalAuth: user.isLocalAuth,
|
||||||
|
invitedAt: user.invitedAt,
|
||||||
|
invitedBy: user.invitedBy,
|
||||||
|
workspaceMemberships: user.workspaceMemberships.map((m) => ({
|
||||||
|
workspaceId: m.workspaceId,
|
||||||
|
workspaceName: m.workspace.name,
|
||||||
|
role: m.role,
|
||||||
|
joinedAt: m.joinedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deactivateUser(id: string): Promise<AdminUserResponse> {
|
||||||
|
const existing = await this.prisma.user.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(`User ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.deactivatedAt) {
|
||||||
|
throw new BadRequestException(`User ${id} is already deactivated`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.prisma.user.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deactivatedAt: new Date() },
|
||||||
|
include: {
|
||||||
|
workspaceMemberships: {
|
||||||
|
include: {
|
||||||
|
workspace: { select: { id: true, name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`User deactivated: ${id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
image: user.image,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
deactivatedAt: user.deactivatedAt,
|
||||||
|
isLocalAuth: user.isLocalAuth,
|
||||||
|
invitedAt: user.invitedAt,
|
||||||
|
invitedBy: user.invitedBy,
|
||||||
|
workspaceMemberships: user.workspaceMemberships.map((m) => ({
|
||||||
|
workspaceId: m.workspaceId,
|
||||||
|
workspaceName: m.workspace.name,
|
||||||
|
role: m.role,
|
||||||
|
joinedAt: m.joinedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createWorkspace(dto: CreateWorkspaceDto): Promise<AdminWorkspaceResponse> {
|
||||||
|
const owner = await this.prisma.user.findUnique({ where: { id: dto.ownerId } });
|
||||||
|
if (!owner) {
|
||||||
|
throw new NotFoundException(`User ${dto.ownerId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.workspace.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name,
|
||||||
|
ownerId: dto.ownerId,
|
||||||
|
settings: dto.settings ? (dto.settings as Prisma.InputJsonValue) : {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.workspaceMember.create({
|
||||||
|
data: {
|
||||||
|
workspaceId: created.id,
|
||||||
|
userId: dto.ownerId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Workspace created: ${workspace.id} with owner ${dto.ownerId}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: workspace.id,
|
||||||
|
name: workspace.name,
|
||||||
|
ownerId: workspace.ownerId,
|
||||||
|
settings: workspace.settings as Record<string, unknown>,
|
||||||
|
createdAt: workspace.createdAt,
|
||||||
|
updatedAt: workspace.updatedAt,
|
||||||
|
memberCount: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateWorkspace(
|
||||||
|
id: string,
|
||||||
|
dto: { name?: string; settings?: Record<string, unknown> }
|
||||||
|
): Promise<AdminWorkspaceResponse> {
|
||||||
|
const existing = await this.prisma.workspace.findUnique({ where: { id } });
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(`Workspace ${id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Prisma.WorkspaceUpdateInput = {};
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
data.name = dto.name;
|
||||||
|
}
|
||||||
|
if (dto.settings !== undefined) {
|
||||||
|
data.settings = dto.settings as Prisma.InputJsonValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = await this.prisma.workspace.update({
|
||||||
|
where: { id },
|
||||||
|
data,
|
||||||
|
include: {
|
||||||
|
_count: { select: { members: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Workspace updated: ${id}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: workspace.id,
|
||||||
|
name: workspace.name,
|
||||||
|
ownerId: workspace.ownerId,
|
||||||
|
settings: workspace.settings as Record<string, unknown>,
|
||||||
|
createdAt: workspace.createdAt,
|
||||||
|
updatedAt: workspace.updatedAt,
|
||||||
|
memberCount: workspace._count.members,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/admin/dto/create-workspace.dto.ts
Normal file
15
apps/api/src/admin/dto/create-workspace.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { IsObject, IsOptional, IsString, IsUUID, MaxLength, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateWorkspaceDto {
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
|
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsUUID("4", { message: "ownerId must be a valid UUID" })
|
||||||
|
ownerId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "settings must be an object" })
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
}
|
||||||
20
apps/api/src/admin/dto/invite-user.dto.ts
Normal file
20
apps/api/src/admin/dto/invite-user.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import { IsEmail, IsEnum, IsOptional, IsString, IsUUID, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class InviteUserDto {
|
||||||
|
@IsEmail({}, { message: "email must be a valid email address" })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
|
||||||
|
workspaceId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||||
|
role?: WorkspaceMemberRole;
|
||||||
|
}
|
||||||
15
apps/api/src/admin/dto/manage-member.dto.ts
Normal file
15
apps/api/src/admin/dto/manage-member.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import { IsEnum, IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
export class AddMemberDto {
|
||||||
|
@IsUUID("4", { message: "userId must be a valid UUID" })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||||
|
role!: WorkspaceMemberRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateMemberRoleDto {
|
||||||
|
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||||
|
role!: WorkspaceMemberRole;
|
||||||
|
}
|
||||||
17
apps/api/src/admin/dto/query-users.dto.ts
Normal file
17
apps/api/src/admin/dto/query-users.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsInt, IsOptional, Max, Min } from "class-validator";
|
||||||
|
import { Type } from "class-transformer";
|
||||||
|
|
||||||
|
export class QueryUsersDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: "page must be an integer" })
|
||||||
|
@Min(1, { message: "page must be at least 1" })
|
||||||
|
page?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt({ message: "limit must be an integer" })
|
||||||
|
@Min(1, { message: "limit must be at least 1" })
|
||||||
|
@Max(100, { message: "limit must not exceed 100" })
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
27
apps/api/src/admin/dto/update-user.dto.ts
Normal file
27
apps/api/src/admin/dto/update-user.dto.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import {
|
||||||
|
IsBoolean,
|
||||||
|
IsDateString,
|
||||||
|
IsObject,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
} from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateUserDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString({}, { message: "deactivatedAt must be a valid ISO 8601 date string" })
|
||||||
|
deactivatedAt?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean({ message: "emailVerified must be a boolean" })
|
||||||
|
emailVerified?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "preferences must be an object" })
|
||||||
|
preferences?: Record<string, unknown>;
|
||||||
|
}
|
||||||
13
apps/api/src/admin/dto/update-workspace.dto.ts
Normal file
13
apps/api/src/admin/dto/update-workspace.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsObject, IsOptional, IsString, MaxLength, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class UpdateWorkspaceDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
|
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsObject({ message: "settings must be an object" })
|
||||||
|
settings?: Record<string, unknown>;
|
||||||
|
}
|
||||||
49
apps/api/src/admin/types/admin.types.ts
Normal file
49
apps/api/src/admin/types/admin.types.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
|
||||||
|
export interface AdminUserResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
image: string | null;
|
||||||
|
createdAt: Date;
|
||||||
|
deactivatedAt: Date | null;
|
||||||
|
isLocalAuth: boolean;
|
||||||
|
invitedAt: Date | null;
|
||||||
|
invitedBy: string | null;
|
||||||
|
workspaceMemberships: WorkspaceMembershipResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkspaceMembershipResponse {
|
||||||
|
workspaceId: string;
|
||||||
|
workspaceName: string;
|
||||||
|
role: WorkspaceMemberRole;
|
||||||
|
joinedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedResponse<T> {
|
||||||
|
data: T[];
|
||||||
|
meta: {
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvitationResponse {
|
||||||
|
userId: string;
|
||||||
|
invitationToken: string;
|
||||||
|
email: string;
|
||||||
|
invitedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminWorkspaceResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
ownerId: string;
|
||||||
|
settings: Record<string, unknown>;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
memberCount: number;
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
|||||||
import { SpeechModule } from "./speech/speech.module";
|
import { SpeechModule } from "./speech/speech.module";
|
||||||
import { DashboardModule } from "./dashboard/dashboard.module";
|
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||||
import { TerminalModule } from "./terminal/terminal.module";
|
import { TerminalModule } from "./terminal/terminal.module";
|
||||||
|
import { PersonalitiesModule } from "./personalities/personalities.module";
|
||||||
|
import { WorkspacesModule } from "./workspaces/workspaces.module";
|
||||||
|
import { AdminModule } from "./admin/admin.module";
|
||||||
|
import { TeamsModule } from "./teams/teams.module";
|
||||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -105,6 +109,10 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
|||||||
SpeechModule,
|
SpeechModule,
|
||||||
DashboardModule,
|
DashboardModule,
|
||||||
TerminalModule,
|
TerminalModule,
|
||||||
|
PersonalitiesModule,
|
||||||
|
WorkspacesModule,
|
||||||
|
AdminModule,
|
||||||
|
TeamsModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, CsrfController],
|
controllers: [AppController, CsrfController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -361,16 +361,13 @@ describe("AuthController", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("getProfile", () => {
|
describe("getProfile", () => {
|
||||||
it("should return complete user profile with workspace fields", () => {
|
it("should return complete user profile with identity fields", () => {
|
||||||
const mockUser: AuthUser = {
|
const mockUser: AuthUser = {
|
||||||
id: "user-123",
|
id: "user-123",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
name: "Test User",
|
name: "Test User",
|
||||||
image: "https://example.com/avatar.jpg",
|
image: "https://example.com/avatar.jpg",
|
||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
workspaceId: "workspace-123",
|
|
||||||
currentWorkspaceId: "workspace-456",
|
|
||||||
workspaceRole: "admin",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = controller.getProfile(mockUser);
|
const result = controller.getProfile(mockUser);
|
||||||
@@ -381,13 +378,10 @@ describe("AuthController", () => {
|
|||||||
name: mockUser.name,
|
name: mockUser.name,
|
||||||
image: mockUser.image,
|
image: mockUser.image,
|
||||||
emailVerified: mockUser.emailVerified,
|
emailVerified: mockUser.emailVerified,
|
||||||
workspaceId: mockUser.workspaceId,
|
|
||||||
currentWorkspaceId: mockUser.currentWorkspaceId,
|
|
||||||
workspaceRole: mockUser.workspaceRole,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return user profile with optional fields undefined", () => {
|
it("should return user profile with only required fields", () => {
|
||||||
const mockUser: AuthUser = {
|
const mockUser: AuthUser = {
|
||||||
id: "user-123",
|
id: "user-123",
|
||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
@@ -400,12 +394,11 @@ describe("AuthController", () => {
|
|||||||
id: mockUser.id,
|
id: mockUser.id,
|
||||||
email: mockUser.email,
|
email: mockUser.email,
|
||||||
name: mockUser.name,
|
name: mockUser.name,
|
||||||
image: undefined,
|
|
||||||
emailVerified: undefined,
|
|
||||||
workspaceId: undefined,
|
|
||||||
currentWorkspaceId: undefined,
|
|
||||||
workspaceRole: undefined,
|
|
||||||
});
|
});
|
||||||
|
// Workspace fields are not included — served by GET /api/workspaces
|
||||||
|
expect(result).not.toHaveProperty("workspaceId");
|
||||||
|
expect(result).not.toHaveProperty("currentWorkspaceId");
|
||||||
|
expect(result).not.toHaveProperty("workspaceRole");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -72,15 +72,10 @@ export class AuthController {
|
|||||||
if (user.emailVerified !== undefined) {
|
if (user.emailVerified !== undefined) {
|
||||||
profile.emailVerified = user.emailVerified;
|
profile.emailVerified = user.emailVerified;
|
||||||
}
|
}
|
||||||
if (user.workspaceId !== undefined) {
|
|
||||||
profile.workspaceId = user.workspaceId;
|
// Workspace context is served by GET /api/workspaces, not the auth profile.
|
||||||
}
|
// The deprecated workspaceId/currentWorkspaceId/workspaceRole fields on
|
||||||
if (user.currentWorkspaceId !== undefined) {
|
// AuthUser are never populated by BetterAuth and are omitted here.
|
||||||
profile.currentWorkspaceId = user.currentWorkspaceId;
|
|
||||||
}
|
|
||||||
if (user.workspaceRole !== undefined) {
|
|
||||||
profile.workspaceRole = user.workspaceRole;
|
|
||||||
}
|
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ import { PrismaModule } from "../prisma/prisma.module";
|
|||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthGuard } from "./guards/auth.guard";
|
import { AuthGuard } from "./guards/auth.guard";
|
||||||
|
import { LocalAuthController } from "./local/local-auth.controller";
|
||||||
|
import { LocalAuthService } from "./local/local-auth.service";
|
||||||
|
import { LocalAuthEnabledGuard } from "./local/local-auth.guard";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController, LocalAuthController],
|
||||||
providers: [AuthService, AuthGuard],
|
providers: [AuthService, AuthGuard, LocalAuthService, LocalAuthEnabledGuard],
|
||||||
exports: [AuthService, AuthGuard],
|
exports: [AuthService, AuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
10
apps/api/src/auth/local/dto/local-login.dto.ts
Normal file
10
apps/api/src/auth/local/dto/local-login.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsEmail, IsString, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class LocalLoginDto {
|
||||||
|
@IsEmail({}, { message: "email must be a valid email address" })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "password must be a string" })
|
||||||
|
@MinLength(1, { message: "password must not be empty" })
|
||||||
|
password!: string;
|
||||||
|
}
|
||||||
20
apps/api/src/auth/local/dto/local-setup.dto.ts
Normal file
20
apps/api/src/auth/local/dto/local-setup.dto.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { IsEmail, IsString, MinLength, MaxLength } from "class-validator";
|
||||||
|
|
||||||
|
export class LocalSetupDto {
|
||||||
|
@IsEmail({}, { message: "email must be a valid email address" })
|
||||||
|
email!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
|
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "password must be a string" })
|
||||||
|
@MinLength(12, { message: "password must be at least 12 characters" })
|
||||||
|
@MaxLength(128, { message: "password must not exceed 128 characters" })
|
||||||
|
password!: string;
|
||||||
|
|
||||||
|
@IsString({ message: "setupToken must be a string" })
|
||||||
|
@MinLength(1, { message: "setupToken must not be empty" })
|
||||||
|
setupToken!: string;
|
||||||
|
}
|
||||||
232
apps/api/src/auth/local/local-auth.controller.spec.ts
Normal file
232
apps/api/src/auth/local/local-auth.controller.spec.ts
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import {
|
||||||
|
NotFoundException,
|
||||||
|
ForbiddenException,
|
||||||
|
UnauthorizedException,
|
||||||
|
ConflictException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { LocalAuthController } from "./local-auth.controller";
|
||||||
|
import { LocalAuthService } from "./local-auth.service";
|
||||||
|
import { LocalAuthEnabledGuard } from "./local-auth.guard";
|
||||||
|
|
||||||
|
describe("LocalAuthController", () => {
|
||||||
|
let controller: LocalAuthController;
|
||||||
|
let localAuthService: LocalAuthService;
|
||||||
|
|
||||||
|
const mockLocalAuthService = {
|
||||||
|
setup: vi.fn(),
|
||||||
|
login: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRequest = {
|
||||||
|
headers: { "user-agent": "TestAgent/1.0" },
|
||||||
|
ip: "127.0.0.1",
|
||||||
|
socket: { remoteAddress: "127.0.0.1" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalEnv = {
|
||||||
|
ENABLE_LOCAL_AUTH: process.env.ENABLE_LOCAL_AUTH,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
process.env.ENABLE_LOCAL_AUTH = "true";
|
||||||
|
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [LocalAuthController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: LocalAuthService,
|
||||||
|
useValue: mockLocalAuthService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(LocalAuthEnabledGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<LocalAuthController>(LocalAuthController);
|
||||||
|
localAuthService = module.get<LocalAuthService>(LocalAuthService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (originalEnv.ENABLE_LOCAL_AUTH !== undefined) {
|
||||||
|
process.env.ENABLE_LOCAL_AUTH = originalEnv.ENABLE_LOCAL_AUTH;
|
||||||
|
} else {
|
||||||
|
delete process.env.ENABLE_LOCAL_AUTH;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setup", () => {
|
||||||
|
const setupDto = {
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Break Glass Admin",
|
||||||
|
password: "securePassword123!",
|
||||||
|
setupToken: "valid-token-123",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockSetupResult = {
|
||||||
|
user: {
|
||||||
|
id: "user-uuid-123",
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Break Glass Admin",
|
||||||
|
isLocalAuth: true,
|
||||||
|
createdAt: new Date("2026-02-28T00:00:00Z"),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
token: "session-token-abc",
|
||||||
|
expiresAt: new Date("2026-03-07T00:00:00Z"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should create a break-glass user and return user data with session", async () => {
|
||||||
|
mockLocalAuthService.setup.mockResolvedValue(mockSetupResult);
|
||||||
|
|
||||||
|
const result = await controller.setup(setupDto, mockRequest as never);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
user: mockSetupResult.user,
|
||||||
|
session: mockSetupResult.session,
|
||||||
|
});
|
||||||
|
expect(mockLocalAuthService.setup).toHaveBeenCalledWith(
|
||||||
|
"admin@example.com",
|
||||||
|
"Break Glass Admin",
|
||||||
|
"securePassword123!",
|
||||||
|
"valid-token-123",
|
||||||
|
"127.0.0.1",
|
||||||
|
"TestAgent/1.0"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should extract client IP from x-forwarded-for header", async () => {
|
||||||
|
mockLocalAuthService.setup.mockResolvedValue(mockSetupResult);
|
||||||
|
const reqWithProxy = {
|
||||||
|
...mockRequest,
|
||||||
|
headers: {
|
||||||
|
...mockRequest.headers,
|
||||||
|
"x-forwarded-for": "203.0.113.50, 70.41.3.18",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await controller.setup(setupDto, reqWithProxy as never);
|
||||||
|
|
||||||
|
expect(mockLocalAuthService.setup).toHaveBeenCalledWith(
|
||||||
|
expect.any(String) as string,
|
||||||
|
expect.any(String) as string,
|
||||||
|
expect.any(String) as string,
|
||||||
|
expect.any(String) as string,
|
||||||
|
"203.0.113.50",
|
||||||
|
"TestAgent/1.0"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should propagate ForbiddenException from service", async () => {
|
||||||
|
mockLocalAuthService.setup.mockRejectedValue(new ForbiddenException("Invalid setup token"));
|
||||||
|
|
||||||
|
await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow(
|
||||||
|
ForbiddenException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should propagate ConflictException from service", async () => {
|
||||||
|
mockLocalAuthService.setup.mockRejectedValue(
|
||||||
|
new ConflictException("A user with this email already exists")
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(controller.setup(setupDto, mockRequest as never)).rejects.toThrow(
|
||||||
|
ConflictException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("login", () => {
|
||||||
|
const loginDto = {
|
||||||
|
email: "admin@example.com",
|
||||||
|
password: "securePassword123!",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockLoginResult = {
|
||||||
|
user: {
|
||||||
|
id: "user-uuid-123",
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Break Glass Admin",
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
token: "session-token-abc",
|
||||||
|
expiresAt: new Date("2026-03-07T00:00:00Z"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should authenticate and return user data with session", async () => {
|
||||||
|
mockLocalAuthService.login.mockResolvedValue(mockLoginResult);
|
||||||
|
|
||||||
|
const result = await controller.login(loginDto, mockRequest as never);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
user: mockLoginResult.user,
|
||||||
|
session: mockLoginResult.session,
|
||||||
|
});
|
||||||
|
expect(mockLocalAuthService.login).toHaveBeenCalledWith(
|
||||||
|
"admin@example.com",
|
||||||
|
"securePassword123!",
|
||||||
|
"127.0.0.1",
|
||||||
|
"TestAgent/1.0"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should propagate UnauthorizedException from service", async () => {
|
||||||
|
mockLocalAuthService.login.mockRejectedValue(
|
||||||
|
new UnauthorizedException("Invalid email or password")
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(controller.login(loginDto, mockRequest as never)).rejects.toThrow(
|
||||||
|
UnauthorizedException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("LocalAuthEnabledGuard", () => {
|
||||||
|
let guard: LocalAuthEnabledGuard;
|
||||||
|
|
||||||
|
const originalEnv = process.env.ENABLE_LOCAL_AUTH;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
guard = new LocalAuthEnabledGuard();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalEnv !== undefined) {
|
||||||
|
process.env.ENABLE_LOCAL_AUTH = originalEnv;
|
||||||
|
} else {
|
||||||
|
delete process.env.ENABLE_LOCAL_AUTH;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow access when ENABLE_LOCAL_AUTH is true", () => {
|
||||||
|
process.env.ENABLE_LOCAL_AUTH = "true";
|
||||||
|
|
||||||
|
expect(guard.canActivate()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is not set", () => {
|
||||||
|
delete process.env.ENABLE_LOCAL_AUTH;
|
||||||
|
|
||||||
|
expect(() => guard.canActivate()).toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is false", () => {
|
||||||
|
process.env.ENABLE_LOCAL_AUTH = "false";
|
||||||
|
|
||||||
|
expect(() => guard.canActivate()).toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when ENABLE_LOCAL_AUTH is empty", () => {
|
||||||
|
process.env.ENABLE_LOCAL_AUTH = "";
|
||||||
|
|
||||||
|
expect(() => guard.canActivate()).toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
apps/api/src/auth/local/local-auth.controller.ts
Normal file
81
apps/api/src/auth/local/local-auth.controller.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Body,
|
||||||
|
UseGuards,
|
||||||
|
Req,
|
||||||
|
Logger,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Throttle } from "@nestjs/throttler";
|
||||||
|
import type { Request as ExpressRequest } from "express";
|
||||||
|
import { SkipCsrf } from "../../common/decorators/skip-csrf.decorator";
|
||||||
|
import { LocalAuthService } from "./local-auth.service";
|
||||||
|
import { LocalAuthEnabledGuard } from "./local-auth.guard";
|
||||||
|
import { LocalLoginDto } from "./dto/local-login.dto";
|
||||||
|
import { LocalSetupDto } from "./dto/local-setup.dto";
|
||||||
|
|
||||||
|
@Controller("auth/local")
|
||||||
|
@UseGuards(LocalAuthEnabledGuard)
|
||||||
|
export class LocalAuthController {
|
||||||
|
private readonly logger = new Logger(LocalAuthController.name);
|
||||||
|
|
||||||
|
constructor(private readonly localAuthService: LocalAuthService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-time break-glass user creation.
|
||||||
|
* Requires BREAKGLASS_SETUP_TOKEN from environment.
|
||||||
|
*/
|
||||||
|
@Post("setup")
|
||||||
|
@SkipCsrf()
|
||||||
|
@Throttle({ strict: { limit: 5, ttl: 60000 } })
|
||||||
|
async setup(@Body() dto: LocalSetupDto, @Req() req: ExpressRequest) {
|
||||||
|
const ipAddress = this.getClientIp(req);
|
||||||
|
const userAgent = req.headers["user-agent"];
|
||||||
|
|
||||||
|
this.logger.log(`Break-glass setup attempt from ${ipAddress}`);
|
||||||
|
|
||||||
|
const result = await this.localAuthService.setup(
|
||||||
|
dto.email,
|
||||||
|
dto.name,
|
||||||
|
dto.password,
|
||||||
|
dto.setupToken,
|
||||||
|
ipAddress,
|
||||||
|
userAgent
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: result.user,
|
||||||
|
session: result.session,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Break-glass login with email + password.
|
||||||
|
*/
|
||||||
|
@Post("login")
|
||||||
|
@SkipCsrf()
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Throttle({ strict: { limit: 10, ttl: 60000 } })
|
||||||
|
async login(@Body() dto: LocalLoginDto, @Req() req: ExpressRequest) {
|
||||||
|
const ipAddress = this.getClientIp(req);
|
||||||
|
const userAgent = req.headers["user-agent"];
|
||||||
|
|
||||||
|
const result = await this.localAuthService.login(dto.email, dto.password, ipAddress, userAgent);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user: result.user,
|
||||||
|
session: result.session,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getClientIp(req: ExpressRequest): string {
|
||||||
|
const forwardedFor = req.headers["x-forwarded-for"];
|
||||||
|
if (forwardedFor) {
|
||||||
|
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
||||||
|
return ips?.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
}
|
||||||
|
return req.ip ?? req.socket.remoteAddress ?? "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
15
apps/api/src/auth/local/local-auth.guard.ts
Normal file
15
apps/api/src/auth/local/local-auth.guard.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Injectable, CanActivate, NotFoundException } from "@nestjs/common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard that checks if local authentication is enabled via ENABLE_LOCAL_AUTH env var.
|
||||||
|
* Returns 404 when disabled so endpoints are invisible to callers.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LocalAuthEnabledGuard implements CanActivate {
|
||||||
|
canActivate(): boolean {
|
||||||
|
if (process.env.ENABLE_LOCAL_AUTH !== "true") {
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
389
apps/api/src/auth/local/local-auth.service.spec.ts
Normal file
389
apps/api/src/auth/local/local-auth.service.spec.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import {
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { hash } from "bcryptjs";
|
||||||
|
import { LocalAuthService } from "./local-auth.service";
|
||||||
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
|
|
||||||
|
describe("LocalAuthService", () => {
|
||||||
|
let service: LocalAuthService;
|
||||||
|
|
||||||
|
const mockTxSession = {
|
||||||
|
create: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTxWorkspace = {
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTxWorkspaceMember = {
|
||||||
|
create: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTxUser = {
|
||||||
|
create: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTx = {
|
||||||
|
user: mockTxUser,
|
||||||
|
workspace: mockTxWorkspace,
|
||||||
|
workspaceMember: mockTxWorkspaceMember,
|
||||||
|
session: mockTxSession,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
session: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
$transaction: vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementation((fn: (tx: typeof mockTx) => Promise<unknown>) => fn(mockTx)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalEnv = {
|
||||||
|
BREAKGLASS_SETUP_TOKEN: process.env.BREAKGLASS_SETUP_TOKEN,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
LocalAuthService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<LocalAuthService>(LocalAuthService);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
if (originalEnv.BREAKGLASS_SETUP_TOKEN !== undefined) {
|
||||||
|
process.env.BREAKGLASS_SETUP_TOKEN = originalEnv.BREAKGLASS_SETUP_TOKEN;
|
||||||
|
} else {
|
||||||
|
delete process.env.BREAKGLASS_SETUP_TOKEN;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setup", () => {
|
||||||
|
const validSetupArgs = {
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Break Glass Admin",
|
||||||
|
password: "securePassword123!",
|
||||||
|
setupToken: "valid-token-123",
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCreatedUser = {
|
||||||
|
id: "user-uuid-123",
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Break Glass Admin",
|
||||||
|
isLocalAuth: true,
|
||||||
|
createdAt: new Date("2026-02-28T00:00:00Z"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkspace = {
|
||||||
|
id: "workspace-uuid-123",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.BREAKGLASS_SETUP_TOKEN = "valid-token-123";
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
mockTxUser.create.mockResolvedValue(mockCreatedUser);
|
||||||
|
mockTxWorkspace.findFirst.mockResolvedValue(mockWorkspace);
|
||||||
|
mockTxWorkspaceMember.create.mockResolvedValue({});
|
||||||
|
mockTxSession.create.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a local auth user with hashed password", async () => {
|
||||||
|
const result = await service.setup(
|
||||||
|
validSetupArgs.email,
|
||||||
|
validSetupArgs.name,
|
||||||
|
validSetupArgs.password,
|
||||||
|
validSetupArgs.setupToken
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.user).toEqual(mockCreatedUser);
|
||||||
|
expect(result.session.token).toBeDefined();
|
||||||
|
expect(result.session.token.length).toBeGreaterThan(0);
|
||||||
|
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
||||||
|
expect(result.session.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
|
||||||
|
expect(mockTxUser.create).toHaveBeenCalledWith({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Break Glass Admin",
|
||||||
|
isLocalAuth: true,
|
||||||
|
emailVerified: true,
|
||||||
|
passwordHash: expect.any(String) as string,
|
||||||
|
}),
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
isLocalAuth: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should assign OWNER role on default workspace", async () => {
|
||||||
|
await service.setup(
|
||||||
|
validSetupArgs.email,
|
||||||
|
validSetupArgs.name,
|
||||||
|
validSetupArgs.password,
|
||||||
|
validSetupArgs.setupToken
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
workspaceId: "workspace-uuid-123",
|
||||||
|
userId: "user-uuid-123",
|
||||||
|
role: "OWNER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a new workspace if none exists", async () => {
|
||||||
|
mockTxWorkspace.findFirst.mockResolvedValue(null);
|
||||||
|
mockTxWorkspace.create.mockResolvedValue({ id: "new-workspace-uuid" });
|
||||||
|
|
||||||
|
await service.setup(
|
||||||
|
validSetupArgs.email,
|
||||||
|
validSetupArgs.name,
|
||||||
|
validSetupArgs.password,
|
||||||
|
validSetupArgs.setupToken
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockTxWorkspace.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
name: "Default Workspace",
|
||||||
|
ownerId: "user-uuid-123",
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
expect(mockTxWorkspaceMember.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
workspaceId: "new-workspace-uuid",
|
||||||
|
userId: "user-uuid-123",
|
||||||
|
role: "OWNER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a BetterAuth-compatible session", async () => {
|
||||||
|
await service.setup(
|
||||||
|
validSetupArgs.email,
|
||||||
|
validSetupArgs.name,
|
||||||
|
validSetupArgs.password,
|
||||||
|
validSetupArgs.setupToken,
|
||||||
|
"192.168.1.1",
|
||||||
|
"TestAgent/1.0"
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockTxSession.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
userId: "user-uuid-123",
|
||||||
|
token: expect.any(String) as string,
|
||||||
|
expiresAt: expect.any(Date) as Date,
|
||||||
|
ipAddress: "192.168.1.1",
|
||||||
|
userAgent: "TestAgent/1.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when BREAKGLASS_SETUP_TOKEN is not set", async () => {
|
||||||
|
delete process.env.BREAKGLASS_SETUP_TOKEN;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.setup(
|
||||||
|
validSetupArgs.email,
|
||||||
|
validSetupArgs.name,
|
||||||
|
validSetupArgs.password,
|
||||||
|
validSetupArgs.setupToken
|
||||||
|
)
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when BREAKGLASS_SETUP_TOKEN is empty", async () => {
|
||||||
|
process.env.BREAKGLASS_SETUP_TOKEN = "";
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.setup(
|
||||||
|
validSetupArgs.email,
|
||||||
|
validSetupArgs.name,
|
||||||
|
validSetupArgs.password,
|
||||||
|
validSetupArgs.setupToken
|
||||||
|
)
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when setup token does not match", async () => {
|
||||||
|
await expect(
|
||||||
|
service.setup(
|
||||||
|
validSetupArgs.email,
|
||||||
|
validSetupArgs.name,
|
||||||
|
validSetupArgs.password,
|
||||||
|
"wrong-token"
|
||||||
|
)
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when email already exists", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||||
|
id: "existing-user",
|
||||||
|
email: "admin@example.com",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.setup(
|
||||||
|
validSetupArgs.email,
|
||||||
|
validSetupArgs.name,
|
||||||
|
validSetupArgs.password,
|
||||||
|
validSetupArgs.setupToken
|
||||||
|
)
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return session token and expiry", async () => {
|
||||||
|
const result = await service.setup(
|
||||||
|
validSetupArgs.email,
|
||||||
|
validSetupArgs.name,
|
||||||
|
validSetupArgs.password,
|
||||||
|
validSetupArgs.setupToken
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(typeof result.session.token).toBe("string");
|
||||||
|
expect(result.session.token.length).toBe(64); // 32 bytes hex
|
||||||
|
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("login", () => {
|
||||||
|
const validPasswordHash = "$2a$12$LJ3m4ys3Lz/YgP7xYz5k5uU6b5F6X1234567890abcdefghijkl";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Create a real bcrypt hash for testing
|
||||||
|
const realHash = await hash("securePassword123!", 4); // Low rounds for test speed
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||||
|
id: "user-uuid-123",
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Break Glass Admin",
|
||||||
|
isLocalAuth: true,
|
||||||
|
passwordHash: realHash,
|
||||||
|
deactivatedAt: null,
|
||||||
|
});
|
||||||
|
mockPrismaService.session.create.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should authenticate a valid local auth user", async () => {
|
||||||
|
const result = await service.login("admin@example.com", "securePassword123!");
|
||||||
|
|
||||||
|
expect(result.user).toEqual({
|
||||||
|
id: "user-uuid-123",
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Break Glass Admin",
|
||||||
|
});
|
||||||
|
expect(result.session.token).toBeDefined();
|
||||||
|
expect(result.session.expiresAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a session with ip and user agent", async () => {
|
||||||
|
await service.login("admin@example.com", "securePassword123!", "10.0.0.1", "Mozilla/5.0");
|
||||||
|
|
||||||
|
expect(mockPrismaService.session.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
userId: "user-uuid-123",
|
||||||
|
token: expect.any(String) as string,
|
||||||
|
expiresAt: expect.any(Date) as Date,
|
||||||
|
ipAddress: "10.0.0.1",
|
||||||
|
userAgent: "Mozilla/5.0",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when user does not exist", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.login("nonexistent@example.com", "password123456")).rejects.toThrow(
|
||||||
|
UnauthorizedException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when user is not a local auth user", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||||
|
id: "user-uuid-123",
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "OIDC User",
|
||||||
|
isLocalAuth: false,
|
||||||
|
passwordHash: null,
|
||||||
|
deactivatedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.login("admin@example.com", "password123456")).rejects.toThrow(
|
||||||
|
UnauthorizedException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when user is deactivated", async () => {
|
||||||
|
const realHash = await hash("securePassword123!", 4);
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||||
|
id: "user-uuid-123",
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Deactivated User",
|
||||||
|
isLocalAuth: true,
|
||||||
|
passwordHash: realHash,
|
||||||
|
deactivatedAt: new Date("2026-01-01"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
|
||||||
|
new UnauthorizedException("Account has been deactivated")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject when password is incorrect", async () => {
|
||||||
|
await expect(service.login("admin@example.com", "wrongPassword123!")).rejects.toThrow(
|
||||||
|
UnauthorizedException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw InternalServerError when local auth user has no password hash", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue({
|
||||||
|
id: "user-uuid-123",
|
||||||
|
email: "admin@example.com",
|
||||||
|
name: "Broken User",
|
||||||
|
isLocalAuth: true,
|
||||||
|
passwordHash: null,
|
||||||
|
deactivatedAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.login("admin@example.com", "securePassword123!")).rejects.toThrow(
|
||||||
|
InternalServerErrorException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not reveal whether email exists in error messages", async () => {
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.login("nonexistent@example.com", "password123456");
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(UnauthorizedException);
|
||||||
|
expect((error as UnauthorizedException).message).toBe("Invalid email or password");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
230
apps/api/src/auth/local/local-auth.service.ts
Normal file
230
apps/api/src/auth/local/local-auth.service.ts
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
ForbiddenException,
|
||||||
|
UnauthorizedException,
|
||||||
|
ConflictException,
|
||||||
|
InternalServerErrorException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import { hash, compare } from "bcryptjs";
|
||||||
|
import { randomBytes, timingSafeEqual } from "crypto";
|
||||||
|
import { PrismaService } from "../../prisma/prisma.service";
|
||||||
|
|
||||||
|
const BCRYPT_ROUNDS = 12;
|
||||||
|
|
||||||
|
/** Session expiry: 7 days (matches BetterAuth config in auth.config.ts) */
|
||||||
|
const SESSION_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
interface SetupResult {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
isLocalAuth: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
session: {
|
||||||
|
token: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResult {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
session: {
|
||||||
|
token: string;
|
||||||
|
expiresAt: Date;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class LocalAuthService {
|
||||||
|
private readonly logger = new Logger(LocalAuthService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-time break-glass user creation.
|
||||||
|
* Validates the setup token, creates a local auth user with bcrypt-hashed password,
|
||||||
|
* and assigns OWNER role on the default workspace.
|
||||||
|
*/
|
||||||
|
async setup(
|
||||||
|
email: string,
|
||||||
|
name: string,
|
||||||
|
password: string,
|
||||||
|
setupToken: string,
|
||||||
|
ipAddress?: string,
|
||||||
|
userAgent?: string
|
||||||
|
): Promise<SetupResult> {
|
||||||
|
this.validateSetupToken(setupToken);
|
||||||
|
|
||||||
|
const existing = await this.prisma.user.findUnique({ where: { email } });
|
||||||
|
if (existing) {
|
||||||
|
throw new ConflictException("A user with this email already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await hash(password, BCRYPT_ROUNDS);
|
||||||
|
|
||||||
|
const result = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const user = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
email,
|
||||||
|
name,
|
||||||
|
isLocalAuth: true,
|
||||||
|
passwordHash,
|
||||||
|
emailVerified: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
isLocalAuth: true,
|
||||||
|
createdAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find or create a default workspace and assign OWNER role
|
||||||
|
await this.assignDefaultWorkspace(tx, user.id);
|
||||||
|
|
||||||
|
// Create a BetterAuth-compatible session
|
||||||
|
const session = await this.createSession(tx, user.id, ipAddress, userAgent);
|
||||||
|
|
||||||
|
return { user, session };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Break-glass user created: ${email}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Break-glass login: verify email + password against bcrypt hash.
|
||||||
|
* Only works for users with isLocalAuth=true.
|
||||||
|
*/
|
||||||
|
async login(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
ipAddress?: string,
|
||||||
|
userAgent?: string
|
||||||
|
): Promise<LoginResult> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { email },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
name: true,
|
||||||
|
isLocalAuth: true,
|
||||||
|
passwordHash: true,
|
||||||
|
deactivatedAt: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user?.isLocalAuth) {
|
||||||
|
throw new UnauthorizedException("Invalid email or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.deactivatedAt) {
|
||||||
|
throw new UnauthorizedException("Account has been deactivated");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user.passwordHash) {
|
||||||
|
this.logger.error(`Local auth user ${email} has no password hash`);
|
||||||
|
throw new InternalServerErrorException("Account configuration error");
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordValid = await compare(password, user.passwordHash);
|
||||||
|
if (!passwordValid) {
|
||||||
|
throw new UnauthorizedException("Invalid email or password");
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await this.createSession(this.prisma, user.id, ipAddress, userAgent);
|
||||||
|
|
||||||
|
this.logger.log(`Break-glass login: ${email}`);
|
||||||
|
return {
|
||||||
|
user: { id: user.id, email: user.email, name: user.name },
|
||||||
|
session,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate the setup token against the environment variable.
|
||||||
|
*/
|
||||||
|
private validateSetupToken(token: string): void {
|
||||||
|
const expectedToken = process.env.BREAKGLASS_SETUP_TOKEN;
|
||||||
|
|
||||||
|
if (!expectedToken || expectedToken.trim() === "") {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
"Break-glass setup is not configured. Set BREAKGLASS_SETUP_TOKEN environment variable."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenBuffer = Buffer.from(token);
|
||||||
|
const expectedBuffer = Buffer.from(expectedToken);
|
||||||
|
if (
|
||||||
|
tokenBuffer.length !== expectedBuffer.length ||
|
||||||
|
!timingSafeEqual(tokenBuffer, expectedBuffer)
|
||||||
|
) {
|
||||||
|
this.logger.warn("Invalid break-glass setup token attempt");
|
||||||
|
throw new ForbiddenException("Invalid setup token");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first workspace or create a default one, then assign OWNER role.
|
||||||
|
*/
|
||||||
|
private async assignDefaultWorkspace(
|
||||||
|
tx: Parameters<Parameters<PrismaService["$transaction"]>[0]>[0],
|
||||||
|
userId: string
|
||||||
|
): Promise<void> {
|
||||||
|
let workspace = await tx.workspace.findFirst({
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
workspace ??= await tx.workspace.create({
|
||||||
|
data: {
|
||||||
|
name: "Default Workspace",
|
||||||
|
ownerId: userId,
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.workspaceMember.create({
|
||||||
|
data: {
|
||||||
|
workspaceId: workspace.id,
|
||||||
|
userId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a BetterAuth-compatible session record.
|
||||||
|
*/
|
||||||
|
private async createSession(
|
||||||
|
tx: { session: { create: typeof PrismaService.prototype.session.create } },
|
||||||
|
userId: string,
|
||||||
|
ipAddress?: string,
|
||||||
|
userAgent?: string
|
||||||
|
): Promise<{ token: string; expiresAt: Date }> {
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
const expiresAt = new Date(Date.now() + SESSION_EXPIRY_MS);
|
||||||
|
|
||||||
|
await tx.session.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
token,
|
||||||
|
expiresAt,
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
userAgent: userAgent ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { token, expiresAt };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -110,10 +110,10 @@ export class WorkspaceGuard implements CanActivate {
|
|||||||
return paramWorkspaceId;
|
return paramWorkspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check request body
|
// 3. Check request body (body may be undefined for GET requests despite Express typings)
|
||||||
const bodyWorkspaceId = request.body.workspaceId;
|
const body = request.body as Record<string, unknown> | undefined;
|
||||||
if (typeof bodyWorkspaceId === "string") {
|
if (body && typeof body.workspaceId === "string") {
|
||||||
return bodyWorkspaceId;
|
return body.workspaceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Check query string (backward compatibility for existing clients)
|
// 4. Check query string (backward compatibility for existing clients)
|
||||||
|
|||||||
@@ -270,7 +270,7 @@ describe("sanitizeForLogging", () => {
|
|||||||
const duration = Date.now() - start;
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
expect(result.password).toBe("[REDACTED]");
|
expect(result.password).toBe("[REDACTED]");
|
||||||
expect(duration).toBeLessThan(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");
|
.set("X-API-Key", "test-coordinator-key");
|
||||||
|
|
||||||
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
expect(response.status).toBe(HttpStatus.TOO_MANY_REQUESTS);
|
||||||
});
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Per-API-Key Rate Limiting", () => {
|
describe("Per-API-Key Rate Limiting", () => {
|
||||||
|
|||||||
@@ -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 { Type } from "class-transformer";
|
||||||
import { EntryStatus } from "@prisma/client";
|
import { EntryStatus, Visibility } from "@prisma/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DTO for querying knowledge entries (list endpoint)
|
* DTO for querying knowledge entries (list endpoint)
|
||||||
@@ -10,10 +10,28 @@ export class EntryQueryDto {
|
|||||||
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||||
status?: EntryStatus;
|
status?: EntryStatus;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(Visibility, { message: "visibility must be a valid Visibility" })
|
||||||
|
visibility?: Visibility;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ message: "tag must be a string" })
|
@IsString({ message: "tag must be a string" })
|
||||||
tag?: string;
|
tag?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "search must be a string" })
|
||||||
|
search?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["updatedAt", "createdAt", "title"], {
|
||||||
|
message: "sortBy must be updatedAt, createdAt, or title",
|
||||||
|
})
|
||||||
|
sortBy?: "updatedAt" | "createdAt" | "title";
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" })
|
||||||
|
sortOrder?: "asc" | "desc";
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@IsInt({ message: "page must be an integer" })
|
@IsInt({ message: "page must be an integer" })
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ export class KnowledgeService {
|
|||||||
where.status = query.status;
|
where.status = query.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.visibility) {
|
||||||
|
where.visibility = query.visibility;
|
||||||
|
}
|
||||||
|
|
||||||
if (query.tag) {
|
if (query.tag) {
|
||||||
where.tags = {
|
where.tags = {
|
||||||
some: {
|
some: {
|
||||||
@@ -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
|
// Get total count
|
||||||
const total = await this.prisma.knowledgeEntry.count({ where });
|
const total = await this.prisma.knowledgeEntry.count({ where });
|
||||||
|
|
||||||
@@ -71,9 +89,7 @@ export class KnowledgeService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy,
|
||||||
updatedAt: "desc",
|
|
||||||
},
|
|
||||||
skip,
|
skip,
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,59 +1,38 @@
|
|||||||
import {
|
import { FormalityLevel } from "@prisma/client";
|
||||||
IsString,
|
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||||
IsOptional,
|
|
||||||
IsBoolean,
|
|
||||||
IsNumber,
|
|
||||||
IsInt,
|
|
||||||
IsUUID,
|
|
||||||
MinLength,
|
|
||||||
MaxLength,
|
|
||||||
Min,
|
|
||||||
Max,
|
|
||||||
} 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 {
|
export class CreatePersonalityDto {
|
||||||
@IsString()
|
@IsString({ message: "name must be a string" })
|
||||||
@MinLength(1)
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
@MaxLength(100)
|
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||||
name!: string; // unique identifier slug
|
name!: string;
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@MinLength(1)
|
|
||||||
@MaxLength(200)
|
|
||||||
displayName!: string; // human-readable name
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString({ message: "description must be a string" })
|
||||||
@MaxLength(1000)
|
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsString()
|
@IsString({ message: "tone must be a string" })
|
||||||
@MinLength(10)
|
@MinLength(1, { message: "tone must not be empty" })
|
||||||
systemPrompt!: string;
|
@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()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||||
@Min(0)
|
|
||||||
@Max(2)
|
|
||||||
temperature?: number; // null = use provider default
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
maxTokens?: number; // null = use provider default
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsUUID("4")
|
|
||||||
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean({ message: "isActive must be a boolean" })
|
||||||
isEnabled?: boolean;
|
isActive?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from "./create-personality.dto";
|
export * from "./create-personality.dto";
|
||||||
export * from "./update-personality.dto";
|
export * from "./update-personality.dto";
|
||||||
|
export * from "./personality-query.dto";
|
||||||
|
|||||||
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 {
|
import { FormalityLevel } from "@prisma/client";
|
||||||
IsString,
|
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||||
IsOptional,
|
|
||||||
IsBoolean,
|
|
||||||
IsNumber,
|
|
||||||
IsInt,
|
|
||||||
IsUUID,
|
|
||||||
MinLength,
|
|
||||||
MaxLength,
|
|
||||||
Min,
|
|
||||||
Max,
|
|
||||||
} 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 {
|
export class UpdatePersonalityDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString({ message: "name must be a string" })
|
||||||
@MinLength(1)
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
@MaxLength(100)
|
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||||
name?: string; // unique identifier slug
|
name?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString({ message: "description must be a string" })
|
||||||
@MinLength(1)
|
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
||||||
@MaxLength(200)
|
|
||||||
displayName?: string; // human-readable name
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
@MaxLength(1000)
|
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString({ message: "tone must be a string" })
|
||||||
@MinLength(10)
|
@MinLength(1, { message: "tone must not be empty" })
|
||||||
systemPrompt?: string;
|
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
||||||
|
tone?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsNumber()
|
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
||||||
@Min(0)
|
formalityLevel?: FormalityLevel;
|
||||||
@Max(2)
|
|
||||||
temperature?: number; // null = use provider default
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsString({ message: "systemPromptTemplate must be a string" })
|
||||||
@Min(1)
|
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
||||||
maxTokens?: number; // null = use provider default
|
systemPromptTemplate?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID("4")
|
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||||
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean({ message: "isActive must be a boolean" })
|
||||||
isEnabled?: 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 {
|
export interface PersonalityResponse {
|
||||||
id!: string;
|
id: string;
|
||||||
workspaceId!: string;
|
workspaceId: string;
|
||||||
name!: string; // unique identifier slug
|
name: string;
|
||||||
displayName!: string; // human-readable name
|
description: string | null;
|
||||||
description!: string | null;
|
tone: string;
|
||||||
systemPrompt!: string;
|
formalityLevel: FormalityLevel;
|
||||||
temperature!: number | null; // null = use provider default
|
systemPromptTemplate: string;
|
||||||
maxTokens!: number | null; // null = use provider default
|
isDefault: boolean;
|
||||||
llmProviderInstanceId!: string | null; // FK to LlmProviderInstance
|
isActive: boolean;
|
||||||
isDefault!: boolean;
|
createdAt: Date;
|
||||||
isEnabled!: boolean;
|
updatedAt: Date;
|
||||||
createdAt!: Date;
|
|
||||||
updatedAt!: Date;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,36 +2,32 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
|||||||
import { Test, TestingModule } from "@nestjs/testing";
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PersonalitiesController } from "./personalities.controller";
|
import { PersonalitiesController } from "./personalities.controller";
|
||||||
import { PersonalitiesService } from "./personalities.service";
|
import { PersonalitiesService } from "./personalities.service";
|
||||||
import { 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 { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
|
import { FormalityLevel } from "@prisma/client";
|
||||||
|
|
||||||
describe("PersonalitiesController", () => {
|
describe("PersonalitiesController", () => {
|
||||||
let controller: PersonalitiesController;
|
let controller: PersonalitiesController;
|
||||||
let service: PersonalitiesService;
|
let service: PersonalitiesService;
|
||||||
|
|
||||||
const mockWorkspaceId = "workspace-123";
|
const mockWorkspaceId = "workspace-123";
|
||||||
const mockUserId = "user-123";
|
|
||||||
const mockPersonalityId = "personality-123";
|
const mockPersonalityId = "personality-123";
|
||||||
|
|
||||||
|
/** API response shape (frontend field names) */
|
||||||
const mockPersonality = {
|
const mockPersonality = {
|
||||||
id: mockPersonalityId,
|
id: mockPersonalityId,
|
||||||
workspaceId: mockWorkspaceId,
|
workspaceId: mockWorkspaceId,
|
||||||
name: "professional-assistant",
|
name: "professional-assistant",
|
||||||
displayName: "Professional Assistant",
|
|
||||||
description: "A professional communication assistant",
|
description: "A professional communication assistant",
|
||||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
tone: "professional",
|
||||||
temperature: 0.7,
|
formalityLevel: FormalityLevel.FORMAL,
|
||||||
maxTokens: 2000,
|
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
||||||
llmProviderInstanceId: "provider-123",
|
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
isEnabled: true,
|
isActive: true,
|
||||||
createdAt: new Date(),
|
createdAt: new Date("2026-01-01"),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date("2026-01-01"),
|
||||||
};
|
|
||||||
|
|
||||||
const mockRequest = {
|
|
||||||
user: { id: mockUserId },
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockPersonalitiesService = {
|
const mockPersonalitiesService = {
|
||||||
@@ -57,46 +53,43 @@ describe("PersonalitiesController", () => {
|
|||||||
})
|
})
|
||||||
.overrideGuard(AuthGuard)
|
.overrideGuard(AuthGuard)
|
||||||
.useValue({ canActivate: () => true })
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(WorkspaceGuard)
|
||||||
|
.useValue({
|
||||||
|
canActivate: (ctx: {
|
||||||
|
switchToHttp: () => { getRequest: () => { workspaceId: string } };
|
||||||
|
}) => {
|
||||||
|
const req = ctx.switchToHttp().getRequest();
|
||||||
|
req.workspaceId = mockWorkspaceId;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.overrideGuard(PermissionGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
||||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||||
|
|
||||||
// Reset mocks
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findAll", () => {
|
describe("findAll", () => {
|
||||||
it("should return all personalities", async () => {
|
it("should return success response with personalities list", async () => {
|
||||||
const mockPersonalities = [mockPersonality];
|
const mockList = [mockPersonality];
|
||||||
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
|
mockPersonalitiesService.findAll.mockResolvedValue(mockList);
|
||||||
|
|
||||||
const result = await controller.findAll(mockRequest);
|
const result = await controller.findAll(mockWorkspaceId, {});
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonalities);
|
expect(result).toEqual({ success: true, data: mockList });
|
||||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
|
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("findOne", () => {
|
it("should pass isActive query filter to service", async () => {
|
||||||
it("should return a personality by id", async () => {
|
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
|
||||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
|
||||||
|
|
||||||
const result = await controller.findOne(mockRequest, mockPersonalityId);
|
await controller.findAll(mockWorkspaceId, { isActive: true });
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonality);
|
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true });
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -104,32 +97,40 @@ describe("PersonalitiesController", () => {
|
|||||||
it("should return the default personality", async () => {
|
it("should return the default personality", async () => {
|
||||||
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
||||||
|
|
||||||
const result = await controller.findDefault(mockRequest);
|
const result = await controller.findDefault(mockWorkspaceId);
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonality);
|
expect(result).toEqual(mockPersonality);
|
||||||
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
|
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", () => {
|
describe("create", () => {
|
||||||
it("should create a new personality", async () => {
|
it("should create a new personality", async () => {
|
||||||
const createDto: CreatePersonalityDto = {
|
const createDto: CreatePersonalityDto = {
|
||||||
name: "casual-helper",
|
name: "casual-helper",
|
||||||
displayName: "Casual Helper",
|
|
||||||
description: "A casual helper",
|
description: "A casual helper",
|
||||||
systemPrompt: "You are a casual assistant.",
|
tone: "casual",
|
||||||
temperature: 0.8,
|
formalityLevel: FormalityLevel.CASUAL,
|
||||||
maxTokens: 1500,
|
systemPromptTemplate: "You are a casual assistant.",
|
||||||
};
|
};
|
||||||
|
|
||||||
mockPersonalitiesService.create.mockResolvedValue({
|
const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false };
|
||||||
...mockPersonality,
|
mockPersonalitiesService.create.mockResolvedValue(created);
|
||||||
...createDto,
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -138,15 +139,13 @@ describe("PersonalitiesController", () => {
|
|||||||
it("should update a personality", async () => {
|
it("should update a personality", async () => {
|
||||||
const updateDto: UpdatePersonalityDto = {
|
const updateDto: UpdatePersonalityDto = {
|
||||||
description: "Updated description",
|
description: "Updated description",
|
||||||
temperature: 0.9,
|
tone: "enthusiastic",
|
||||||
};
|
};
|
||||||
|
|
||||||
mockPersonalitiesService.update.mockResolvedValue({
|
const updated = { ...mockPersonality, ...updateDto };
|
||||||
...mockPersonality,
|
mockPersonalitiesService.update.mockResolvedValue(updated);
|
||||||
...updateDto,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await controller.update(mockRequest, mockPersonalityId, updateDto);
|
const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||||
|
|
||||||
expect(result).toMatchObject(updateDto);
|
expect(result).toMatchObject(updateDto);
|
||||||
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||||
@@ -157,7 +156,7 @@ describe("PersonalitiesController", () => {
|
|||||||
it("should delete a personality", async () => {
|
it("should delete a personality", async () => {
|
||||||
mockPersonalitiesService.delete.mockResolvedValue(undefined);
|
mockPersonalitiesService.delete.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await controller.delete(mockRequest, mockPersonalityId);
|
await controller.delete(mockWorkspaceId, mockPersonalityId);
|
||||||
|
|
||||||
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||||
});
|
});
|
||||||
@@ -165,12 +164,10 @@ describe("PersonalitiesController", () => {
|
|||||||
|
|
||||||
describe("setDefault", () => {
|
describe("setDefault", () => {
|
||||||
it("should set a personality as default", async () => {
|
it("should set a personality as default", async () => {
|
||||||
mockPersonalitiesService.setDefault.mockResolvedValue({
|
const updated = { ...mockPersonality, isDefault: true };
|
||||||
...mockPersonality,
|
mockPersonalitiesService.setDefault.mockResolvedValue(updated);
|
||||||
isDefault: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await controller.setDefault(mockRequest, mockPersonalityId);
|
const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||||
|
|
||||||
expect(result).toMatchObject({ isDefault: true });
|
expect(result).toMatchObject({ isDefault: true });
|
||||||
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||||
|
|||||||
@@ -6,105 +6,122 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Req,
|
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
|
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||||
import { PersonalitiesService } from "./personalities.service";
|
import { PersonalitiesService } from "./personalities.service";
|
||||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
import { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||||
import { Personality } from "./entities/personality.entity";
|
import { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||||
|
import { PersonalityQueryDto } from "./dto/personality-query.dto";
|
||||||
interface AuthenticatedRequest {
|
import type { PersonalityResponse } from "./entities/personality.entity";
|
||||||
user: { id: string };
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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")
|
@Controller("personalities")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||||
export class PersonalitiesController {
|
export class PersonalitiesController {
|
||||||
constructor(private readonly personalitiesService: PersonalitiesService) {}
|
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()
|
@Get()
|
||||||
async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> {
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
return this.personalitiesService.findAll(req.workspaceId);
|
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")
|
@Get("default")
|
||||||
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
return this.personalitiesService.findDefault(req.workspaceId);
|
async findDefault(@Workspace() workspaceId: string): Promise<PersonalityResponse> {
|
||||||
|
return this.personalitiesService.findDefault(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a personality by its unique name
|
* GET /api/personalities/:id
|
||||||
*/
|
* Get a single personality by ID.
|
||||||
@Get("by-name/:name")
|
|
||||||
async findByName(
|
|
||||||
@Req() req: AuthenticatedRequest,
|
|
||||||
@Param("name") name: string
|
|
||||||
): Promise<Personality> {
|
|
||||||
return this.personalitiesService.findByName(req.workspaceId, name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a personality by ID
|
|
||||||
*/
|
*/
|
||||||
@Get(":id")
|
@Get(":id")
|
||||||
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
return this.personalitiesService.findOne(req.workspaceId, id);
|
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()
|
@Post()
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.CREATED)
|
||||||
|
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||||
async create(
|
async create(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Workspace() workspaceId: string,
|
||||||
@Body() dto: CreatePersonalityDto
|
@Body() dto: CreatePersonalityDto
|
||||||
): Promise<Personality> {
|
): Promise<PersonalityResponse> {
|
||||||
return this.personalitiesService.create(req.workspaceId, dto);
|
return this.personalitiesService.create(workspaceId, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update a personality
|
* PATCH /api/personalities/:id
|
||||||
|
* Update an existing personality.
|
||||||
*/
|
*/
|
||||||
@Patch(":id")
|
@Patch(":id")
|
||||||
|
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||||
async update(
|
async update(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Workspace() workspaceId: string,
|
||||||
@Param("id") id: string,
|
@Param("id") id: string,
|
||||||
@Body() dto: UpdatePersonalityDto
|
@Body() dto: UpdatePersonalityDto
|
||||||
): Promise<Personality> {
|
): Promise<PersonalityResponse> {
|
||||||
return this.personalitiesService.update(req.workspaceId, id, dto);
|
return this.personalitiesService.update(workspaceId, id, dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a personality
|
* DELETE /api/personalities/:id
|
||||||
|
* Delete a personality.
|
||||||
*/
|
*/
|
||||||
@Delete(":id")
|
@Delete(":id")
|
||||||
@HttpCode(HttpStatus.NO_CONTENT)
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
|
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||||
return this.personalitiesService.delete(req.workspaceId, id);
|
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")
|
@Post(":id/set-default")
|
||||||
|
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||||
async setDefault(
|
async setDefault(
|
||||||
@Req() req: AuthenticatedRequest,
|
@Workspace() workspaceId: string,
|
||||||
@Param("id") id: string
|
@Param("id") id: string
|
||||||
): Promise<Personality> {
|
): Promise<PersonalityResponse> {
|
||||||
return this.personalitiesService.setDefault(req.workspaceId, id);
|
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 { Test, TestingModule } from "@nestjs/testing";
|
||||||
import { PersonalitiesService } from "./personalities.service";
|
import { PersonalitiesService } from "./personalities.service";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { 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 { NotFoundException, ConflictException } from "@nestjs/common";
|
||||||
|
import { FormalityLevel } from "@prisma/client";
|
||||||
|
|
||||||
describe("PersonalitiesService", () => {
|
describe("PersonalitiesService", () => {
|
||||||
let service: PersonalitiesService;
|
let service: PersonalitiesService;
|
||||||
@@ -11,22 +13,39 @@ describe("PersonalitiesService", () => {
|
|||||||
|
|
||||||
const mockWorkspaceId = "workspace-123";
|
const mockWorkspaceId = "workspace-123";
|
||||||
const mockPersonalityId = "personality-123";
|
const mockPersonalityId = "personality-123";
|
||||||
const mockProviderId = "provider-123";
|
|
||||||
|
|
||||||
const mockPersonality = {
|
/** Raw Prisma record shape (uses Prisma field names) */
|
||||||
|
const mockPrismaRecord = {
|
||||||
id: mockPersonalityId,
|
id: mockPersonalityId,
|
||||||
workspaceId: mockWorkspaceId,
|
workspaceId: mockWorkspaceId,
|
||||||
name: "professional-assistant",
|
name: "professional-assistant",
|
||||||
displayName: "Professional Assistant",
|
displayName: "Professional Assistant",
|
||||||
description: "A professional communication assistant",
|
description: "A professional communication assistant",
|
||||||
|
tone: "professional",
|
||||||
|
formalityLevel: FormalityLevel.FORMAL,
|
||||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||||
temperature: 0.7,
|
temperature: 0.7,
|
||||||
maxTokens: 2000,
|
maxTokens: 2000,
|
||||||
llmProviderInstanceId: mockProviderId,
|
llmProviderInstanceId: "provider-123",
|
||||||
isDefault: true,
|
isDefault: true,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
createdAt: new Date(),
|
createdAt: new Date("2026-01-01"),
|
||||||
updatedAt: new Date(),
|
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 = {
|
const mockPrismaService = {
|
||||||
@@ -37,9 +56,7 @@ describe("PersonalitiesService", () => {
|
|||||||
create: vi.fn(),
|
create: vi.fn(),
|
||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
delete: vi.fn(),
|
delete: vi.fn(),
|
||||||
count: vi.fn(),
|
|
||||||
},
|
},
|
||||||
$transaction: vi.fn((callback) => callback(mockPrismaService)),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -56,44 +73,54 @@ describe("PersonalitiesService", () => {
|
|||||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||||
prisma = module.get<PrismaService>(PrismaService);
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
|
||||||
// Reset mocks
|
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("create", () => {
|
describe("create", () => {
|
||||||
const createDto: CreatePersonalityDto = {
|
const createDto: CreatePersonalityDto = {
|
||||||
name: "casual-helper",
|
name: "casual-helper",
|
||||||
displayName: "Casual Helper",
|
|
||||||
description: "A casual communication helper",
|
description: "A casual communication helper",
|
||||||
systemPrompt: "You are a casual assistant.",
|
tone: "casual",
|
||||||
temperature: 0.8,
|
formalityLevel: FormalityLevel.CASUAL,
|
||||||
maxTokens: 1500,
|
systemPromptTemplate: "You are a casual assistant.",
|
||||||
llmProviderInstanceId: mockProviderId,
|
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.findFirst.mockResolvedValue(null);
|
||||||
mockPrismaService.personality.create.mockResolvedValue({
|
mockPrismaService.personality.create.mockResolvedValue(createdRecord);
|
||||||
...mockPersonality,
|
|
||||||
...createDto,
|
|
||||||
id: "new-personality-id",
|
|
||||||
isDefault: false,
|
|
||||||
isEnabled: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await service.create(mockWorkspaceId, createDto);
|
const result = await service.create(mockWorkspaceId, createDto);
|
||||||
|
|
||||||
expect(result).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({
|
expect(prisma.personality.create).toHaveBeenCalledWith({
|
||||||
data: {
|
data: {
|
||||||
workspaceId: mockWorkspaceId,
|
workspaceId: mockWorkspaceId,
|
||||||
name: createDto.name,
|
name: createDto.name,
|
||||||
displayName: createDto.displayName,
|
displayName: createDto.name,
|
||||||
description: createDto.description ?? null,
|
description: createDto.description ?? null,
|
||||||
systemPrompt: createDto.systemPrompt,
|
tone: createDto.tone,
|
||||||
temperature: createDto.temperature ?? null,
|
formalityLevel: createDto.formalityLevel,
|
||||||
maxTokens: createDto.maxTokens ?? null,
|
systemPrompt: createDto.systemPromptTemplate,
|
||||||
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
|
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
},
|
},
|
||||||
@@ -101,68 +128,73 @@ describe("PersonalitiesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw ConflictException when name already exists", async () => {
|
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);
|
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should unset other defaults when creating a new default personality", async () => {
|
it("should unset other defaults when creating a new default personality", async () => {
|
||||||
const createDefaultDto = { ...createDto, isDefault: true };
|
const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true };
|
||||||
// First call to findFirst checks for name conflict (should be null)
|
const otherDefault = { ...mockPrismaRecord, id: "other-id" };
|
||||||
// Second call to findFirst finds the existing default personality
|
|
||||||
mockPrismaService.personality.findFirst
|
mockPrismaService.personality.findFirst
|
||||||
.mockResolvedValueOnce(null) // No name conflict
|
.mockResolvedValueOnce(null) // name conflict check
|
||||||
.mockResolvedValueOnce(mockPersonality); // Existing default
|
.mockResolvedValueOnce(otherDefault); // existing default lookup
|
||||||
mockPrismaService.personality.update.mockResolvedValue({
|
mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false });
|
||||||
...mockPersonality,
|
|
||||||
isDefault: false,
|
|
||||||
});
|
|
||||||
mockPrismaService.personality.create.mockResolvedValue({
|
mockPrismaService.personality.create.mockResolvedValue({
|
||||||
...mockPersonality,
|
...createdRecord,
|
||||||
...createDefaultDto,
|
isDefault: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.create(mockWorkspaceId, createDefaultDto);
|
await service.create(mockWorkspaceId, createDefaultDto);
|
||||||
|
|
||||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||||
where: { id: mockPersonalityId },
|
where: { id: "other-id" },
|
||||||
data: { isDefault: false },
|
data: { isDefault: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findAll", () => {
|
describe("findAll", () => {
|
||||||
it("should return all personalities for a workspace", async () => {
|
it("should return mapped response list for a workspace", async () => {
|
||||||
const mockPersonalities = [mockPersonality];
|
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
||||||
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
|
|
||||||
|
|
||||||
const result = await service.findAll(mockWorkspaceId);
|
const result = await service.findAll(mockWorkspaceId);
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonalities);
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toEqual(mockResponse);
|
||||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||||
where: { workspaceId: mockWorkspaceId },
|
where: { workspaceId: mockWorkspaceId },
|
||||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should filter by isActive when provided", async () => {
|
||||||
|
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
||||||
|
|
||||||
|
await service.findAll(mockWorkspaceId, { isActive: true });
|
||||||
|
|
||||||
|
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { workspaceId: mockWorkspaceId, isEnabled: true },
|
||||||
|
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("findOne", () => {
|
describe("findOne", () => {
|
||||||
it("should return a personality by id", async () => {
|
it("should return a mapped personality response by id", async () => {
|
||||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||||
|
|
||||||
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
|
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonality);
|
expect(result).toEqual(mockResponse);
|
||||||
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
|
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||||
where: {
|
where: { id: mockPersonalityId, workspaceId: mockWorkspaceId },
|
||||||
id: mockPersonalityId,
|
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException when personality not found", async () => {
|
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(
|
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||||
NotFoundException
|
NotFoundException
|
||||||
@@ -171,17 +203,14 @@ describe("PersonalitiesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("findByName", () => {
|
describe("findByName", () => {
|
||||||
it("should return a personality by name", async () => {
|
it("should return a mapped personality response by name", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||||
|
|
||||||
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
|
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonality);
|
expect(result).toEqual(mockResponse);
|
||||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||||
where: {
|
where: { workspaceId: mockWorkspaceId, name: "professional-assistant" },
|
||||||
workspaceId: mockWorkspaceId,
|
|
||||||
name: "professional-assistant",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -196,11 +225,11 @@ describe("PersonalitiesService", () => {
|
|||||||
|
|
||||||
describe("findDefault", () => {
|
describe("findDefault", () => {
|
||||||
it("should return the default personality", async () => {
|
it("should return the default personality", async () => {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||||
|
|
||||||
const result = await service.findDefault(mockWorkspaceId);
|
const result = await service.findDefault(mockWorkspaceId);
|
||||||
|
|
||||||
expect(result).toEqual(mockPersonality);
|
expect(result).toEqual(mockResponse);
|
||||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||||
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
|
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
|
||||||
});
|
});
|
||||||
@@ -216,41 +245,45 @@ describe("PersonalitiesService", () => {
|
|||||||
describe("update", () => {
|
describe("update", () => {
|
||||||
const updateDto: UpdatePersonalityDto = {
|
const updateDto: UpdatePersonalityDto = {
|
||||||
description: "Updated description",
|
description: "Updated description",
|
||||||
temperature: 0.9,
|
tone: "formal",
|
||||||
|
isActive: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should update a personality", async () => {
|
it("should update a personality and return mapped response", async () => {
|
||||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
const updatedRecord = {
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
...mockPrismaRecord,
|
||||||
mockPrismaService.personality.update.mockResolvedValue({
|
description: updateDto.description,
|
||||||
...mockPersonality,
|
tone: updateDto.tone,
|
||||||
...updateDto,
|
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);
|
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||||
|
|
||||||
expect(result).toMatchObject(updateDto);
|
expect(result.description).toBe(updateDto.description);
|
||||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
expect(result.tone).toBe(updateDto.tone);
|
||||||
where: { id: mockPersonalityId },
|
expect(result.isActive).toBe(false);
|
||||||
data: updateDto,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException when personality not found", async () => {
|
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(
|
await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
|
||||||
NotFoundException
|
NotFoundException
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw ConflictException when updating to existing name", async () => {
|
it("should throw ConflictException when updating to an existing name", async () => {
|
||||||
const updateNameDto = { name: "existing-name" };
|
const updateNameDto: UpdatePersonalityDto = { name: "existing-name" };
|
||||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
const conflictRecord = { ...mockPrismaRecord, id: "different-id" };
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue({
|
|
||||||
...mockPersonality,
|
mockPrismaService.personality.findFirst
|
||||||
id: "different-id",
|
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||||
});
|
.mockResolvedValueOnce(conflictRecord); // name conflict
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
|
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
|
||||||
@@ -258,14 +291,16 @@ describe("PersonalitiesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should unset other defaults when setting as default", async () => {
|
it("should unset other defaults when setting as default", async () => {
|
||||||
const updateDefaultDto = { isDefault: true };
|
const updateDefaultDto: UpdatePersonalityDto = { isDefault: true };
|
||||||
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
||||||
|
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
||||||
|
|
||||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
mockPrismaService.personality.findFirst
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
|
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||||
|
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
||||||
mockPrismaService.personality.update
|
mockPrismaService.personality.update
|
||||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
||||||
.mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
|
.mockResolvedValueOnce(updatedRecord);
|
||||||
|
|
||||||
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
|
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
|
||||||
|
|
||||||
@@ -273,16 +308,12 @@ describe("PersonalitiesService", () => {
|
|||||||
where: { id: "other-id" },
|
where: { id: "other-id" },
|
||||||
data: { isDefault: false },
|
data: { isDefault: false },
|
||||||
});
|
});
|
||||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
|
||||||
where: { id: mockPersonalityId },
|
|
||||||
data: updateDefaultDto,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("delete", () => {
|
describe("delete", () => {
|
||||||
it("should delete a personality", async () => {
|
it("should delete a personality", async () => {
|
||||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||||
mockPrismaService.personality.delete.mockResolvedValue(undefined);
|
mockPrismaService.personality.delete.mockResolvedValue(undefined);
|
||||||
|
|
||||||
await service.delete(mockWorkspaceId, mockPersonalityId);
|
await service.delete(mockWorkspaceId, mockPersonalityId);
|
||||||
@@ -293,7 +324,7 @@ describe("PersonalitiesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException when personality not found", async () => {
|
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(
|
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||||
NotFoundException
|
NotFoundException
|
||||||
@@ -303,30 +334,27 @@ describe("PersonalitiesService", () => {
|
|||||||
|
|
||||||
describe("setDefault", () => {
|
describe("setDefault", () => {
|
||||||
it("should set a personality as default", async () => {
|
it("should set a personality as default", async () => {
|
||||||
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
||||||
const updatedPersonality = { ...mockPersonality, isDefault: true };
|
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
||||||
|
|
||||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
mockPrismaService.personality.findFirst
|
||||||
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
|
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||||
|
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
||||||
mockPrismaService.personality.update
|
mockPrismaService.personality.update
|
||||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
||||||
.mockResolvedValueOnce(updatedPersonality); // Set new default
|
.mockResolvedValueOnce(updatedRecord);
|
||||||
|
|
||||||
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
|
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||||
|
|
||||||
expect(result).toMatchObject({ isDefault: true });
|
expect(result.isDefault).toBe(true);
|
||||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
|
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||||
where: { id: "other-id" },
|
|
||||||
data: { isDefault: false },
|
|
||||||
});
|
|
||||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
|
||||||
where: { id: mockPersonalityId },
|
where: { id: mockPersonalityId },
|
||||||
data: { isDefault: true },
|
data: { isDefault: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should throw NotFoundException when personality not found", async () => {
|
it("should throw NotFoundException when personality not found", async () => {
|
||||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||||
NotFoundException
|
NotFoundException
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
|
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
|
||||||
|
import type { FormalityLevel, Personality } from "@prisma/client";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||||
import { Personality } from "./entities/personality.entity";
|
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()
|
@Injectable()
|
||||||
export class PersonalitiesService {
|
export class PersonalitiesService {
|
||||||
@@ -12,11 +19,30 @@ export class PersonalitiesService {
|
|||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a Prisma Personality record to the API response shape.
|
||||||
|
*/
|
||||||
|
private toResponse(personality: Personality): PersonalityResponse {
|
||||||
|
return {
|
||||||
|
id: personality.id,
|
||||||
|
workspaceId: personality.workspaceId,
|
||||||
|
name: personality.name,
|
||||||
|
description: personality.description,
|
||||||
|
tone: personality.tone,
|
||||||
|
formalityLevel: personality.formalityLevel,
|
||||||
|
systemPromptTemplate: personality.systemPrompt,
|
||||||
|
isDefault: personality.isDefault,
|
||||||
|
isActive: personality.isEnabled,
|
||||||
|
createdAt: personality.createdAt,
|
||||||
|
updatedAt: personality.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new personality
|
* Create a new personality
|
||||||
*/
|
*/
|
||||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
|
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<PersonalityResponse> {
|
||||||
// Check for duplicate name
|
// Check for duplicate name within workspace
|
||||||
const existing = await this.prisma.personality.findFirst({
|
const existing = await this.prisma.personality.findFirst({
|
||||||
where: { workspaceId, name: dto.name },
|
where: { workspaceId, name: dto.name },
|
||||||
});
|
});
|
||||||
@@ -25,7 +51,7 @@ export class PersonalitiesService {
|
|||||||
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
|
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If creating a default personality, unset other defaults
|
// If creating as default, unset other defaults first
|
||||||
if (dto.isDefault) {
|
if (dto.isDefault) {
|
||||||
await this.unsetOtherDefaults(workspaceId);
|
await this.unsetOtherDefaults(workspaceId);
|
||||||
}
|
}
|
||||||
@@ -34,36 +60,43 @@ export class PersonalitiesService {
|
|||||||
data: {
|
data: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
displayName: dto.displayName,
|
displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
|
||||||
description: dto.description ?? null,
|
description: dto.description ?? null,
|
||||||
systemPrompt: dto.systemPrompt,
|
tone: dto.tone,
|
||||||
temperature: dto.temperature ?? null,
|
formalityLevel: dto.formalityLevel,
|
||||||
maxTokens: dto.maxTokens ?? null,
|
systemPrompt: dto.systemPromptTemplate,
|
||||||
llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
|
|
||||||
isDefault: dto.isDefault ?? false,
|
isDefault: dto.isDefault ?? false,
|
||||||
isEnabled: dto.isEnabled ?? true,
|
isEnabled: dto.isActive ?? true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
|
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[]> {
|
async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise<PersonalityResponse[]> {
|
||||||
return this.prisma.personality.findMany({
|
const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId };
|
||||||
where: { workspaceId },
|
|
||||||
|
if (query?.isActive !== undefined) {
|
||||||
|
where.isEnabled = query.isActive;
|
||||||
|
}
|
||||||
|
|
||||||
|
const personalities = await this.prisma.personality.findMany({
|
||||||
|
where,
|
||||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return personalities.map((p) => this.toResponse(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a specific personality by ID
|
* Find a specific personality by ID
|
||||||
*/
|
*/
|
||||||
async findOne(workspaceId: string, id: string): Promise<Personality> {
|
async findOne(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
||||||
const personality = await this.prisma.personality.findUnique({
|
const personality = await this.prisma.personality.findFirst({
|
||||||
where: { id, workspaceId },
|
where: { id, workspaceId },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -71,13 +104,13 @@ export class PersonalitiesService {
|
|||||||
throw new NotFoundException(`Personality with ID ${id} not found`);
|
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({
|
const personality = await this.prisma.personality.findFirst({
|
||||||
where: { workspaceId, name },
|
where: { workspaceId, name },
|
||||||
});
|
});
|
||||||
@@ -86,13 +119,13 @@ export class PersonalitiesService {
|
|||||||
throw new NotFoundException(`Personality with name "${name}" not found`);
|
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({
|
const personality = await this.prisma.personality.findFirst({
|
||||||
where: { workspaceId, isDefault: true, isEnabled: true },
|
where: { workspaceId, isDefault: true, isEnabled: true },
|
||||||
});
|
});
|
||||||
@@ -101,14 +134,18 @@ export class PersonalitiesService {
|
|||||||
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
|
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return personality;
|
return this.toResponse(personality);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update an existing personality
|
* Update an existing personality
|
||||||
*/
|
*/
|
||||||
async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise<Personality> {
|
async update(
|
||||||
// Check existence
|
workspaceId: string,
|
||||||
|
id: string,
|
||||||
|
dto: UpdatePersonalityDto
|
||||||
|
): Promise<PersonalityResponse> {
|
||||||
|
// Verify existence
|
||||||
await this.findOne(workspaceId, id);
|
await this.findOne(workspaceId, id);
|
||||||
|
|
||||||
// Check for duplicate name if updating name
|
// Check for duplicate name if updating name
|
||||||
@@ -127,20 +164,43 @@ export class PersonalitiesService {
|
|||||||
await this.unsetOtherDefaults(workspaceId, id);
|
await this.unsetOtherDefaults(workspaceId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build update data with field mapping
|
||||||
|
const updateData: {
|
||||||
|
name?: string;
|
||||||
|
displayName?: string;
|
||||||
|
description?: string;
|
||||||
|
tone?: string;
|
||||||
|
formalityLevel?: FormalityLevel;
|
||||||
|
systemPrompt?: string;
|
||||||
|
isDefault?: boolean;
|
||||||
|
isEnabled?: boolean;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (dto.name !== undefined) {
|
||||||
|
updateData.name = dto.name;
|
||||||
|
updateData.displayName = dto.name;
|
||||||
|
}
|
||||||
|
if (dto.description !== undefined) updateData.description = dto.description;
|
||||||
|
if (dto.tone !== undefined) updateData.tone = dto.tone;
|
||||||
|
if (dto.formalityLevel !== undefined) updateData.formalityLevel = dto.formalityLevel;
|
||||||
|
if (dto.systemPromptTemplate !== undefined) updateData.systemPrompt = dto.systemPromptTemplate;
|
||||||
|
if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault;
|
||||||
|
if (dto.isActive !== undefined) updateData.isEnabled = dto.isActive;
|
||||||
|
|
||||||
const personality = await this.prisma.personality.update({
|
const personality = await this.prisma.personality.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: dto,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
|
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
|
||||||
return personality;
|
return this.toResponse(personality);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a personality
|
* Delete a personality
|
||||||
*/
|
*/
|
||||||
async delete(workspaceId: string, id: string): Promise<void> {
|
async delete(workspaceId: string, id: string): Promise<void> {
|
||||||
// Check existence
|
// Verify existence
|
||||||
await this.findOne(workspaceId, id);
|
await this.findOne(workspaceId, id);
|
||||||
|
|
||||||
await this.prisma.personality.delete({
|
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> {
|
async setDefault(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
||||||
// Check existence
|
// Verify existence
|
||||||
await this.findOne(workspaceId, id);
|
await this.findOne(workspaceId, id);
|
||||||
|
|
||||||
// Unset other defaults
|
// Unset other defaults
|
||||||
await this.unsetOtherDefaults(workspaceId, id);
|
await this.unsetOtherDefaults(workspaceId, id);
|
||||||
|
|
||||||
// Set this one as default
|
|
||||||
const personality = await this.prisma.personality.update({
|
const personality = await this.prisma.personality.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { isDefault: true },
|
data: { isDefault: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
|
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
|
||||||
return personality;
|
return this.toResponse(personality);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -178,7 +237,7 @@ export class PersonalitiesService {
|
|||||||
where: {
|
where: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
isDefault: true,
|
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,
|
workspaceId: string,
|
||||||
client: PrismaClient = this
|
client: PrismaClient = this
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
// Use set_config() instead of SET LOCAL so values are safely parameterized.
|
||||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
// 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)
|
* @param client - Optional Prisma client (uses 'this' if not provided)
|
||||||
*/
|
*/
|
||||||
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
|
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
|
||||||
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
await client.$executeRaw`SELECT set_config('app.current_user_id', '', true)`;
|
||||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
|
await client.$executeRaw`SELECT set_config('app.current_workspace_id', '', true)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
13
apps/api/src/teams/dto/create-team.dto.ts
Normal file
13
apps/api/src/teams/dto/create-team.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { IsOptional, IsString, MaxLength, MinLength } from "class-validator";
|
||||||
|
|
||||||
|
export class CreateTeamDto {
|
||||||
|
@IsString({ message: "name must be a string" })
|
||||||
|
@MinLength(1, { message: "name must not be empty" })
|
||||||
|
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString({ message: "description must be a string" })
|
||||||
|
@MaxLength(10000, { message: "description must not exceed 10000 characters" })
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
11
apps/api/src/teams/dto/manage-team-member.dto.ts
Normal file
11
apps/api/src/teams/dto/manage-team-member.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { TeamMemberRole } from "@prisma/client";
|
||||||
|
import { IsEnum, IsOptional, IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
export class ManageTeamMemberDto {
|
||||||
|
@IsUUID("4", { message: "userId must be a valid UUID" })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsEnum(TeamMemberRole, { message: "role must be a valid TeamMemberRole" })
|
||||||
|
role?: TeamMemberRole;
|
||||||
|
}
|
||||||
150
apps/api/src/teams/teams.controller.spec.ts
Normal file
150
apps/api/src/teams/teams.controller.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { TeamMemberRole } from "@prisma/client";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { PermissionGuard, WorkspaceGuard } from "../common/guards";
|
||||||
|
import { TeamsController } from "./teams.controller";
|
||||||
|
import { TeamsService } from "./teams.service";
|
||||||
|
|
||||||
|
describe("TeamsController", () => {
|
||||||
|
let controller: TeamsController;
|
||||||
|
let service: TeamsService;
|
||||||
|
|
||||||
|
const mockTeamsService = {
|
||||||
|
create: vi.fn(),
|
||||||
|
findAll: vi.fn(),
|
||||||
|
addMember: vi.fn(),
|
||||||
|
removeMember: vi.fn(),
|
||||||
|
remove: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
|
const mockTeamId = "550e8400-e29b-41d4-a716-446655440002";
|
||||||
|
const mockUserId = "550e8400-e29b-41d4-a716-446655440003";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [TeamsController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: TeamsService,
|
||||||
|
useValue: mockTeamsService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: vi.fn(() => true) })
|
||||||
|
.overrideGuard(WorkspaceGuard)
|
||||||
|
.useValue({ canActivate: vi.fn(() => true) })
|
||||||
|
.overrideGuard(PermissionGuard)
|
||||||
|
.useValue({ canActivate: vi.fn(() => true) })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<TeamsController>(TeamsController);
|
||||||
|
service = module.get<TeamsService>(TeamsService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(controller).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should create a team in a workspace", async () => {
|
||||||
|
const createDto = {
|
||||||
|
name: "Platform Team",
|
||||||
|
description: "Owns platform services",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdTeam = {
|
||||||
|
id: mockTeamId,
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
name: createDto.name,
|
||||||
|
description: createDto.description,
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTeamsService.create.mockResolvedValue(createdTeam);
|
||||||
|
|
||||||
|
const result = await controller.create(createDto, mockWorkspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(createdTeam);
|
||||||
|
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should list teams in a workspace", async () => {
|
||||||
|
const teams = [
|
||||||
|
{
|
||||||
|
id: mockTeamId,
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
name: "Platform Team",
|
||||||
|
description: "Owns platform services",
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
_count: { members: 2 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockTeamsService.findAll.mockResolvedValue(teams);
|
||||||
|
|
||||||
|
const result = await controller.findAll(mockWorkspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(teams);
|
||||||
|
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addMember", () => {
|
||||||
|
it("should add a member to a team", async () => {
|
||||||
|
const dto = {
|
||||||
|
userId: mockUserId,
|
||||||
|
role: TeamMemberRole.ADMIN,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdTeamMember = {
|
||||||
|
teamId: mockTeamId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: TeamMemberRole.ADMIN,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
user: {
|
||||||
|
id: mockUserId,
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockTeamsService.addMember.mockResolvedValue(createdTeamMember);
|
||||||
|
|
||||||
|
const result = await controller.addMember(mockTeamId, dto, mockWorkspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(createdTeamMember);
|
||||||
|
expect(service.addMember).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId, dto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeMember", () => {
|
||||||
|
it("should remove a member from a team", async () => {
|
||||||
|
mockTeamsService.removeMember.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await controller.removeMember(mockTeamId, mockUserId, mockWorkspaceId);
|
||||||
|
|
||||||
|
expect(service.removeMember).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId, mockUserId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should delete a team", async () => {
|
||||||
|
mockTeamsService.remove.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await controller.remove(mockTeamId, mockWorkspaceId);
|
||||||
|
|
||||||
|
expect(service.remove).toHaveBeenCalledWith(mockWorkspaceId, mockTeamId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
51
apps/api/src/teams/teams.controller.ts
Normal file
51
apps/api/src/teams/teams.controller.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { PermissionGuard, WorkspaceGuard } from "../common/guards";
|
||||||
|
import { Permission, RequirePermission, Workspace } from "../common/decorators";
|
||||||
|
import { CreateTeamDto } from "./dto/create-team.dto";
|
||||||
|
import { ManageTeamMemberDto } from "./dto/manage-team-member.dto";
|
||||||
|
import { TeamsService } from "./teams.service";
|
||||||
|
|
||||||
|
@Controller("workspaces/:workspaceId/teams")
|
||||||
|
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||||
|
export class TeamsController {
|
||||||
|
constructor(private readonly teamsService: TeamsService) {}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||||
|
async create(@Body() createTeamDto: CreateTeamDto, @Workspace() workspaceId: string) {
|
||||||
|
return this.teamsService.create(workspaceId, createTeamDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||||
|
async findAll(@Workspace() workspaceId: string) {
|
||||||
|
return this.teamsService.findAll(workspaceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(":teamId/members")
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||||
|
async addMember(
|
||||||
|
@Param("teamId") teamId: string,
|
||||||
|
@Body() dto: ManageTeamMemberDto,
|
||||||
|
@Workspace() workspaceId: string
|
||||||
|
) {
|
||||||
|
return this.teamsService.addMember(workspaceId, teamId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":teamId/members/:userId")
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||||
|
async removeMember(
|
||||||
|
@Param("teamId") teamId: string,
|
||||||
|
@Param("userId") userId: string,
|
||||||
|
@Workspace() workspaceId: string
|
||||||
|
) {
|
||||||
|
return this.teamsService.removeMember(workspaceId, teamId, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(":teamId")
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||||
|
async remove(@Param("teamId") teamId: string, @Workspace() workspaceId: string) {
|
||||||
|
return this.teamsService.remove(workspaceId, teamId);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/teams/teams.module.ts
Normal file
13
apps/api/src/teams/teams.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { TeamsController } from "./teams.controller";
|
||||||
|
import { TeamsService } from "./teams.service";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [TeamsController],
|
||||||
|
providers: [TeamsService],
|
||||||
|
exports: [TeamsService],
|
||||||
|
})
|
||||||
|
export class TeamsModule {}
|
||||||
286
apps/api/src/teams/teams.service.spec.ts
Normal file
286
apps/api/src/teams/teams.service.spec.ts
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { TeamMemberRole } from "@prisma/client";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { TeamsService } from "./teams.service";
|
||||||
|
|
||||||
|
describe("TeamsService", () => {
|
||||||
|
let service: TeamsService;
|
||||||
|
let prisma: PrismaService;
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
team: {
|
||||||
|
create: vi.fn(),
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findFirst: vi.fn(),
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
},
|
||||||
|
workspaceMember: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
teamMember: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
deleteMany: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
|
const mockTeamId = "550e8400-e29b-41d4-a716-446655440002";
|
||||||
|
const mockUserId = "550e8400-e29b-41d4-a716-446655440003";
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
TeamsService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<TeamsService>(TeamsService);
|
||||||
|
prisma = module.get<PrismaService>(PrismaService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be defined", () => {
|
||||||
|
expect(service).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("create", () => {
|
||||||
|
it("should create a team", async () => {
|
||||||
|
const createDto = {
|
||||||
|
name: "Platform Team",
|
||||||
|
description: "Owns platform services",
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdTeam = {
|
||||||
|
id: mockTeamId,
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
name: createDto.name,
|
||||||
|
description: createDto.description,
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.team.create.mockResolvedValue(createdTeam);
|
||||||
|
|
||||||
|
const result = await service.create(mockWorkspaceId, createDto);
|
||||||
|
|
||||||
|
expect(result).toEqual(createdTeam);
|
||||||
|
expect(prisma.team.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
name: createDto.name,
|
||||||
|
description: createDto.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findAll", () => {
|
||||||
|
it("should list teams for a workspace", async () => {
|
||||||
|
const teams = [
|
||||||
|
{
|
||||||
|
id: mockTeamId,
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
name: "Platform Team",
|
||||||
|
description: "Owns platform services",
|
||||||
|
metadata: {},
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
_count: { members: 1 },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mockPrismaService.team.findMany.mockResolvedValue(teams);
|
||||||
|
|
||||||
|
const result = await service.findAll(mockWorkspaceId);
|
||||||
|
|
||||||
|
expect(result).toEqual(teams);
|
||||||
|
expect(prisma.team.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { workspaceId: mockWorkspaceId },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { members: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addMember", () => {
|
||||||
|
it("should add a workspace member to a team", async () => {
|
||||||
|
const dto = {
|
||||||
|
userId: mockUserId,
|
||||||
|
role: TeamMemberRole.ADMIN,
|
||||||
|
};
|
||||||
|
|
||||||
|
const createdTeamMember = {
|
||||||
|
teamId: mockTeamId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: TeamMemberRole.ADMIN,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
user: {
|
||||||
|
id: mockUserId,
|
||||||
|
name: "Test User",
|
||||||
|
email: "test@example.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||||
|
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||||
|
mockPrismaService.teamMember.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.teamMember.create.mockResolvedValue(createdTeamMember);
|
||||||
|
|
||||||
|
const result = await service.addMember(mockWorkspaceId, mockTeamId, dto);
|
||||||
|
|
||||||
|
expect(result).toEqual(createdTeamMember);
|
||||||
|
expect(prisma.team.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: mockTeamId,
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
expect(prisma.workspaceMember.findUnique).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
expect(prisma.teamMember.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
teamId: mockTeamId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: TeamMemberRole.ADMIN,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should use MEMBER role when role is omitted", async () => {
|
||||||
|
const dto = { userId: mockUserId };
|
||||||
|
|
||||||
|
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||||
|
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||||
|
mockPrismaService.teamMember.findUnique.mockResolvedValue(null);
|
||||||
|
mockPrismaService.teamMember.create.mockResolvedValue({
|
||||||
|
teamId: mockTeamId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: TeamMemberRole.MEMBER,
|
||||||
|
joinedAt: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.addMember(mockWorkspaceId, mockTeamId, dto);
|
||||||
|
|
||||||
|
expect(prisma.teamMember.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
role: TeamMemberRole.MEMBER,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when team does not belong to workspace", async () => {
|
||||||
|
mockPrismaService.team.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
expect(prisma.workspaceMember.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when user is not a workspace member", async () => {
|
||||||
|
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||||
|
mockPrismaService.workspaceMember.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when user is already in the team", async () => {
|
||||||
|
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||||
|
mockPrismaService.workspaceMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||||
|
mockPrismaService.teamMember.findUnique.mockResolvedValue({ userId: mockUserId });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.addMember(mockWorkspaceId, mockTeamId, { userId: mockUserId })
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeMember", () => {
|
||||||
|
it("should remove a member from a team", async () => {
|
||||||
|
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||||
|
mockPrismaService.teamMember.deleteMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
await service.removeMember(mockWorkspaceId, mockTeamId, mockUserId);
|
||||||
|
|
||||||
|
expect(prisma.teamMember.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
teamId: mockTeamId,
|
||||||
|
userId: mockUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when team does not belong to workspace", async () => {
|
||||||
|
mockPrismaService.team.findFirst.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.removeMember(mockWorkspaceId, mockTeamId, mockUserId)).rejects.toThrow(
|
||||||
|
NotFoundException
|
||||||
|
);
|
||||||
|
expect(prisma.teamMember.deleteMany).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when user is not in the team", async () => {
|
||||||
|
mockPrismaService.team.findFirst.mockResolvedValue({ id: mockTeamId });
|
||||||
|
mockPrismaService.teamMember.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
|
|
||||||
|
await expect(service.removeMember(mockWorkspaceId, mockTeamId, mockUserId)).rejects.toThrow(
|
||||||
|
NotFoundException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("remove", () => {
|
||||||
|
it("should delete a team", async () => {
|
||||||
|
mockPrismaService.team.deleteMany.mockResolvedValue({ count: 1 });
|
||||||
|
|
||||||
|
await service.remove(mockWorkspaceId, mockTeamId);
|
||||||
|
|
||||||
|
expect(prisma.team.deleteMany).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
id: mockTeamId,
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw when team is not found", async () => {
|
||||||
|
mockPrismaService.team.deleteMany.mockResolvedValue({ count: 0 });
|
||||||
|
|
||||||
|
await expect(service.remove(mockWorkspaceId, mockTeamId)).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
130
apps/api/src/teams/teams.service.ts
Normal file
130
apps/api/src/teams/teams.service.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { TeamMemberRole } from "@prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { CreateTeamDto } from "./dto/create-team.dto";
|
||||||
|
import { ManageTeamMemberDto } from "./dto/manage-team-member.dto";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TeamsService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async create(workspaceId: string, createTeamDto: CreateTeamDto) {
|
||||||
|
return this.prisma.team.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
name: createTeamDto.name,
|
||||||
|
description: createTeamDto.description ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAll(workspaceId: string) {
|
||||||
|
return this.prisma.team.findMany({
|
||||||
|
where: { workspaceId },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { members: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMember(workspaceId: string, teamId: string, dto: ManageTeamMemberDto) {
|
||||||
|
await this.ensureTeamInWorkspace(workspaceId, teamId);
|
||||||
|
|
||||||
|
const workspaceMember = await this.prisma.workspaceMember.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId: dto.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!workspaceMember) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`User ${dto.userId} must be a workspace member before being added to a team`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingTeamMember = await this.prisma.teamMember.findUnique({
|
||||||
|
where: {
|
||||||
|
teamId_userId: {
|
||||||
|
teamId,
|
||||||
|
userId: dto.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingTeamMember) {
|
||||||
|
throw new ConflictException(`User ${dto.userId} is already a member of team ${teamId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.teamMember.create({
|
||||||
|
data: {
|
||||||
|
teamId,
|
||||||
|
userId: dto.userId,
|
||||||
|
role: dto.role ?? TeamMemberRole.MEMBER,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeMember(workspaceId: string, teamId: string, userId: string): Promise<void> {
|
||||||
|
await this.ensureTeamInWorkspace(workspaceId, teamId);
|
||||||
|
|
||||||
|
const result = await this.prisma.teamMember.deleteMany({
|
||||||
|
where: {
|
||||||
|
teamId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) {
|
||||||
|
throw new NotFoundException(`User ${userId} is not a member of team ${teamId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(workspaceId: string, teamId: string): Promise<void> {
|
||||||
|
const result = await this.prisma.team.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: teamId,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.count === 0) {
|
||||||
|
throw new NotFoundException(`Team with ID ${teamId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureTeamInWorkspace(workspaceId: string, teamId: string): Promise<void> {
|
||||||
|
const team = await this.prisma.team.findFirst({
|
||||||
|
where: {
|
||||||
|
id: teamId,
|
||||||
|
workspaceId,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!team) {
|
||||||
|
throw new NotFoundException(`Team with ID ${teamId} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ describe("TerminalService", () => {
|
|||||||
let service: TerminalService;
|
let service: TerminalService;
|
||||||
let mockSocket: Socket;
|
let mockSocket: Socket;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
// Reset mock implementations
|
// Reset mock implementations
|
||||||
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
|
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
|
||||||
@@ -54,6 +54,8 @@ describe("TerminalService", () => {
|
|||||||
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
|
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
|
||||||
);
|
);
|
||||||
service = new TerminalService();
|
service = new TerminalService();
|
||||||
|
// Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock)
|
||||||
|
await service.onModuleInit();
|
||||||
mockSocket = createMockSocket();
|
mockSocket = createMockSocket();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,19 @@
|
|||||||
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
|
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Injectable, Logger } from "@nestjs/common";
|
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||||
import * as pty from "node-pty";
|
import type { IPty } from "node-pty";
|
||||||
import type { Socket } from "socket.io";
|
import type { Socket } from "socket.io";
|
||||||
import { randomUUID } from "node:crypto";
|
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 */
|
/** Maximum concurrent PTY sessions per workspace */
|
||||||
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
|
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
|
||||||
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
|
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
|
||||||
@@ -31,7 +39,7 @@ const DEFAULT_ROWS = 24;
|
|||||||
export interface TerminalSession {
|
export interface TerminalSession {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
pty: pty.IPty;
|
pty: IPty;
|
||||||
name?: string;
|
name?: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
@@ -53,7 +61,7 @@ export interface SessionCreatedResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TerminalService {
|
export class TerminalService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(TerminalService.name);
|
private readonly logger = new Logger(TerminalService.name);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,13 +74,30 @@ export class TerminalService {
|
|||||||
*/
|
*/
|
||||||
private readonly workspaceSessions = new Map<string, Set<string>>();
|
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.
|
* Create a new PTY session for the given workspace and socket.
|
||||||
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
|
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
|
||||||
*
|
*
|
||||||
* @throws Error if workspace session limit is exceeded
|
* @throws Error if workspace session limit is exceeded or node-pty is unavailable
|
||||||
*/
|
*/
|
||||||
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
|
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 { workspaceId, name, cwd, socketId } = options;
|
||||||
const cols = options.cols ?? DEFAULT_COLS;
|
const cols = options.cols ?? DEFAULT_COLS;
|
||||||
const rows = options.rows ?? DEFAULT_ROWS;
|
const rows = options.rows ?? DEFAULT_ROWS;
|
||||||
|
|||||||
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,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Put,
|
Put,
|
||||||
|
Patch,
|
||||||
Body,
|
Body,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
Request,
|
Request,
|
||||||
@@ -38,7 +39,7 @@ export class PreferencesController {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PUT /api/users/me/preferences
|
* PUT /api/users/me/preferences
|
||||||
* Update current user's preferences
|
* Full replace of current user's preferences
|
||||||
*/
|
*/
|
||||||
@Put()
|
@Put()
|
||||||
async updatePreferences(
|
async updatePreferences(
|
||||||
@@ -53,4 +54,22 @@ export class PreferencesController {
|
|||||||
|
|
||||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/users/me/preferences
|
||||||
|
* Partial update of current user's preferences
|
||||||
|
*/
|
||||||
|
@Patch()
|
||||||
|
async patchPreferences(
|
||||||
|
@Body() updatePreferencesDto: UpdatePreferencesDto,
|
||||||
|
@Request() req: AuthenticatedRequest
|
||||||
|
) {
|
||||||
|
const userId = req.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new UnauthorizedException("Authentication required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 { Logger } from "@nestjs/common";
|
||||||
import { Server, Socket } from "socket.io";
|
import { Server, Socket } from "socket.io";
|
||||||
import { AuthService } from "../auth/auth.service";
|
import { AuthService } from "../auth/auth.service";
|
||||||
|
import { getTrustedOrigins } from "../auth/auth.config";
|
||||||
import { PrismaService } from "../prisma/prisma.service";
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
|
||||||
interface AuthenticatedSocket extends Socket {
|
interface AuthenticatedSocket extends Socket {
|
||||||
@@ -77,7 +78,7 @@ interface StepOutputData {
|
|||||||
*/
|
*/
|
||||||
@WSGateway({
|
@WSGateway({
|
||||||
cors: {
|
cors: {
|
||||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
origin: getTrustedOrigins(),
|
||||||
credentials: true,
|
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
|
* @param client - The socket client
|
||||||
* @returns The token string or undefined if not found
|
* @returns The token string or undefined if not found
|
||||||
*/
|
*/
|
||||||
private extractTokenFromHandshake(client: Socket): string | undefined {
|
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||||
// Check handshake.auth.token (preferred method)
|
// Check handshake.auth.token (preferred method for non-browser clients)
|
||||||
const authToken = client.handshake.auth.token as unknown;
|
const authToken = client.handshake.auth.token as unknown;
|
||||||
if (typeof authToken === "string" && authToken.length > 0) {
|
if (typeof authToken === "string" && authToken.length > 0) {
|
||||||
return authToken;
|
return authToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback: parse session cookie from request headers.
|
||||||
|
// Browsers send httpOnly cookies automatically when withCredentials: true is set
|
||||||
|
// on the socket.io client. BetterAuth uses one of these cookie names depending
|
||||||
|
// on whether the connection is HTTPS (Secure prefix) or HTTP (dev).
|
||||||
|
const cookieHeader = client.handshake.headers.cookie;
|
||||||
|
if (typeof cookieHeader === "string" && cookieHeader.length > 0) {
|
||||||
|
const cookieToken = this.extractTokenFromCookieHeader(cookieHeader);
|
||||||
|
if (cookieToken) {
|
||||||
|
return cookieToken;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback: check query parameters
|
// Fallback: check query parameters
|
||||||
const queryToken = client.handshake.query.token as unknown;
|
const queryToken = client.handshake.query.token as unknown;
|
||||||
if (typeof queryToken === "string" && queryToken.length > 0) {
|
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||||
@@ -197,6 +217,45 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Parse the BetterAuth session token from a raw Cookie header string.
|
||||||
|
*
|
||||||
|
* BetterAuth names the session cookie differently based on the security context:
|
||||||
|
* - `__Secure-better-auth.session_token` — HTTPS with Secure flag
|
||||||
|
* - `better-auth.session_token` — HTTP (development)
|
||||||
|
* - `__Host-better-auth.session_token` — HTTPS with Host prefix
|
||||||
|
*
|
||||||
|
* @param cookieHeader - The raw Cookie header value
|
||||||
|
* @returns The session token value or undefined if no matching cookie found
|
||||||
|
*/
|
||||||
|
private extractTokenFromCookieHeader(cookieHeader: string): string | undefined {
|
||||||
|
const SESSION_COOKIE_NAMES = [
|
||||||
|
"__Secure-better-auth.session_token",
|
||||||
|
"better-auth.session_token",
|
||||||
|
"__Host-better-auth.session_token",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Parse the Cookie header into a key-value map
|
||||||
|
const cookies = Object.fromEntries(
|
||||||
|
cookieHeader.split(";").map((pair) => {
|
||||||
|
const eqIndex = pair.indexOf("=");
|
||||||
|
if (eqIndex === -1) {
|
||||||
|
return [pair.trim(), ""];
|
||||||
|
}
|
||||||
|
return [pair.slice(0, eqIndex).trim(), pair.slice(eqIndex + 1).trim()];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of SESSION_COOKIE_NAMES) {
|
||||||
|
const value = cookies[name];
|
||||||
|
if (typeof value === "string" && value.length > 0) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Handle client disconnect by leaving the workspace room.
|
* @description Handle client disconnect by leaving the workspace room.
|
||||||
* @param client - The socket client containing workspaceId in data.
|
* @param client - The socket client containing workspaceId in data.
|
||||||
|
|||||||
@@ -1,22 +1,14 @@
|
|||||||
import {
|
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Post,
|
|
||||||
Body,
|
|
||||||
Param,
|
|
||||||
UseGuards,
|
|
||||||
Request,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from "@nestjs/common";
|
|
||||||
import { WidgetsService } from "./widgets.service";
|
import { WidgetsService } from "./widgets.service";
|
||||||
import { WidgetDataService } from "./widget-data.service";
|
import { WidgetDataService } from "./widget-data.service";
|
||||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||||
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
||||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
import type { RequestWithWorkspace } from "../common/types/user.types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Controller for widget definition and data endpoints
|
* Controller for widget definition and data endpoints
|
||||||
* All endpoints require authentication
|
* All endpoints require authentication; data endpoints also require workspace context
|
||||||
*/
|
*/
|
||||||
@Controller("widgets")
|
@Controller("widgets")
|
||||||
@UseGuards(AuthGuard)
|
@UseGuards(AuthGuard)
|
||||||
@@ -51,12 +43,9 @@ export class WidgetsController {
|
|||||||
* Get stat card widget data
|
* Get stat card widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/stat-card")
|
@Post("data/stat-card")
|
||||||
async getStatCardData(@Request() req: AuthenticatedRequest, @Body() query: StatCardQueryDto) {
|
@UseGuards(WorkspaceGuard)
|
||||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
|
||||||
if (!workspaceId) {
|
return this.widgetDataService.getStatCardData(req.workspace.id, query);
|
||||||
throw new UnauthorizedException("Workspace ID required");
|
|
||||||
}
|
|
||||||
return this.widgetDataService.getStatCardData(workspaceId, query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,12 +53,9 @@ export class WidgetsController {
|
|||||||
* Get chart widget data
|
* Get chart widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/chart")
|
@Post("data/chart")
|
||||||
async getChartData(@Request() req: AuthenticatedRequest, @Body() query: ChartQueryDto) {
|
@UseGuards(WorkspaceGuard)
|
||||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
|
||||||
if (!workspaceId) {
|
return this.widgetDataService.getChartData(req.workspace.id, query);
|
||||||
throw new UnauthorizedException("Workspace ID required");
|
|
||||||
}
|
|
||||||
return this.widgetDataService.getChartData(workspaceId, query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -77,12 +63,9 @@ export class WidgetsController {
|
|||||||
* Get list widget data
|
* Get list widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/list")
|
@Post("data/list")
|
||||||
async getListData(@Request() req: AuthenticatedRequest, @Body() query: ListQueryDto) {
|
@UseGuards(WorkspaceGuard)
|
||||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
|
||||||
if (!workspaceId) {
|
return this.widgetDataService.getListData(req.workspace.id, query);
|
||||||
throw new UnauthorizedException("Workspace ID required");
|
|
||||||
}
|
|
||||||
return this.widgetDataService.getListData(workspaceId, query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -90,15 +73,12 @@ export class WidgetsController {
|
|||||||
* Get calendar preview widget data
|
* Get calendar preview widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/calendar-preview")
|
@Post("data/calendar-preview")
|
||||||
|
@UseGuards(WorkspaceGuard)
|
||||||
async getCalendarPreviewData(
|
async getCalendarPreviewData(
|
||||||
@Request() req: AuthenticatedRequest,
|
@Request() req: RequestWithWorkspace,
|
||||||
@Body() query: CalendarPreviewQueryDto
|
@Body() query: CalendarPreviewQueryDto
|
||||||
) {
|
) {
|
||||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
return this.widgetDataService.getCalendarPreviewData(req.workspace.id, query);
|
||||||
if (!workspaceId) {
|
|
||||||
throw new UnauthorizedException("Workspace ID required");
|
|
||||||
}
|
|
||||||
return this.widgetDataService.getCalendarPreviewData(workspaceId, query);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,12 +86,9 @@ export class WidgetsController {
|
|||||||
* Get active projects widget data
|
* Get active projects widget data
|
||||||
*/
|
*/
|
||||||
@Post("data/active-projects")
|
@Post("data/active-projects")
|
||||||
async getActiveProjectsData(@Request() req: AuthenticatedRequest) {
|
@UseGuards(WorkspaceGuard)
|
||||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
|
||||||
if (!workspaceId) {
|
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
|
||||||
throw new UnauthorizedException("Workspace ID required");
|
|
||||||
}
|
|
||||||
return this.widgetDataService.getActiveProjectsData(workspaceId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -119,11 +96,8 @@ export class WidgetsController {
|
|||||||
* Get agent chains widget data (active agent sessions)
|
* Get agent chains widget data (active agent sessions)
|
||||||
*/
|
*/
|
||||||
@Post("data/agent-chains")
|
@Post("data/agent-chains")
|
||||||
async getAgentChainsData(@Request() req: AuthenticatedRequest) {
|
@UseGuards(WorkspaceGuard)
|
||||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
|
||||||
if (!workspaceId) {
|
return this.widgetDataService.getAgentChainsData(req.workspace.id);
|
||||||
throw new UnauthorizedException("Workspace ID required");
|
|
||||||
}
|
|
||||||
return this.widgetDataService.getAgentChainsData(workspaceId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
13
apps/api/src/workspaces/dto/add-member.dto.ts
Normal file
13
apps/api/src/workspaces/dto/add-member.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import { IsEnum, IsUUID } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for adding a user to a workspace.
|
||||||
|
*/
|
||||||
|
export class AddMemberDto {
|
||||||
|
@IsUUID("4", { message: "userId must be a valid UUID" })
|
||||||
|
userId!: string;
|
||||||
|
|
||||||
|
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||||
|
role!: WorkspaceMemberRole;
|
||||||
|
}
|
||||||
3
apps/api/src/workspaces/dto/index.ts
Normal file
3
apps/api/src/workspaces/dto/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { AddMemberDto } from "./add-member.dto";
|
||||||
|
export { UpdateMemberRoleDto } from "./update-member-role.dto";
|
||||||
|
export { WorkspaceResponseDto } from "./workspace-response.dto";
|
||||||
10
apps/api/src/workspaces/dto/update-member-role.dto.ts
Normal file
10
apps/api/src/workspaces/dto/update-member-role.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import { IsEnum } from "class-validator";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for updating a workspace member's role.
|
||||||
|
*/
|
||||||
|
export class UpdateMemberRoleDto {
|
||||||
|
@IsEnum(WorkspaceMemberRole, { message: "role must be a valid WorkspaceMemberRole" })
|
||||||
|
role!: WorkspaceMemberRole;
|
||||||
|
}
|
||||||
12
apps/api/src/workspaces/dto/workspace-response.dto.ts
Normal file
12
apps/api/src/workspaces/dto/workspace-response.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Response DTO for a workspace the authenticated user belongs to.
|
||||||
|
*/
|
||||||
|
export class WorkspaceResponseDto {
|
||||||
|
id!: string;
|
||||||
|
name!: string;
|
||||||
|
ownerId!: string;
|
||||||
|
role!: WorkspaceMemberRole;
|
||||||
|
createdAt!: Date;
|
||||||
|
}
|
||||||
3
apps/api/src/workspaces/index.ts
Normal file
3
apps/api/src/workspaces/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { WorkspacesModule } from "./workspaces.module";
|
||||||
|
export { WorkspacesService } from "./workspaces.service";
|
||||||
|
export { WorkspacesController } from "./workspaces.controller";
|
||||||
149
apps/api/src/workspaces/workspaces.controller.spec.ts
Normal file
149
apps/api/src/workspaces/workspaces.controller.spec.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { WorkspacesController } from "./workspaces.controller";
|
||||||
|
import { WorkspacesService } from "./workspaces.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import type { AuthUser } from "@mosaic/shared";
|
||||||
|
|
||||||
|
describe("WorkspacesController", () => {
|
||||||
|
let controller: WorkspacesController;
|
||||||
|
let service: WorkspacesService;
|
||||||
|
|
||||||
|
const mockWorkspacesService = {
|
||||||
|
getUserWorkspaces: vi.fn(),
|
||||||
|
addMember: vi.fn(),
|
||||||
|
updateMemberRole: vi.fn(),
|
||||||
|
removeMember: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUser: AuthUser = {
|
||||||
|
id: "user-1",
|
||||||
|
email: "test@example.com",
|
||||||
|
name: "Test User",
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
controllers: [WorkspacesController],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: WorkspacesService,
|
||||||
|
useValue: mockWorkspacesService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.overrideGuard(AuthGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(WorkspaceGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.overrideGuard(PermissionGuard)
|
||||||
|
.useValue({ canActivate: () => true })
|
||||||
|
.compile();
|
||||||
|
|
||||||
|
controller = module.get<WorkspacesController>(WorkspacesController);
|
||||||
|
service = module.get<WorkspacesService>(WorkspacesService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GET /api/workspaces", () => {
|
||||||
|
it("should call service with authenticated user id", async () => {
|
||||||
|
mockWorkspacesService.getUserWorkspaces.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await controller.getUserWorkspaces(mockUser);
|
||||||
|
|
||||||
|
expect(service.getUserWorkspaces).toHaveBeenCalledWith("user-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return workspace list from service", async () => {
|
||||||
|
const mockWorkspaces = [
|
||||||
|
{
|
||||||
|
id: "ws-1",
|
||||||
|
name: "My Workspace",
|
||||||
|
ownerId: "user-1",
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
mockWorkspacesService.getUserWorkspaces.mockResolvedValueOnce(mockWorkspaces);
|
||||||
|
|
||||||
|
const result = await controller.getUserWorkspaces(mockUser);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockWorkspaces);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should propagate service errors", async () => {
|
||||||
|
mockWorkspacesService.getUserWorkspaces.mockRejectedValueOnce(new Error("Database error"));
|
||||||
|
|
||||||
|
await expect(controller.getUserWorkspaces(mockUser)).rejects.toThrow("Database error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/workspaces/:id/members", () => {
|
||||||
|
it("should call service with workspace id, actor id, and add member dto", async () => {
|
||||||
|
const workspaceId = "ws-1";
|
||||||
|
const addMemberDto = {
|
||||||
|
userId: "user-2",
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
};
|
||||||
|
const mockMember = {
|
||||||
|
workspaceId,
|
||||||
|
userId: "user-2",
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
joinedAt: new Date("2026-02-01"),
|
||||||
|
};
|
||||||
|
mockWorkspacesService.addMember.mockResolvedValueOnce(mockMember);
|
||||||
|
|
||||||
|
const result = await controller.addMember(workspaceId, addMemberDto, mockUser);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockMember);
|
||||||
|
expect(service.addMember).toHaveBeenCalledWith(workspaceId, mockUser.id, addMemberDto);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PATCH /api/workspaces/:id/members/:userId", () => {
|
||||||
|
it("should call service with workspace id, actor id, target user id, and role dto", async () => {
|
||||||
|
const workspaceId = "ws-1";
|
||||||
|
const targetUserId = "user-2";
|
||||||
|
const updateRoleDto = {
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
};
|
||||||
|
const mockMember = {
|
||||||
|
workspaceId,
|
||||||
|
userId: targetUserId,
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
joinedAt: new Date("2026-02-01"),
|
||||||
|
};
|
||||||
|
mockWorkspacesService.updateMemberRole.mockResolvedValueOnce(mockMember);
|
||||||
|
|
||||||
|
const result = await controller.updateMemberRole(
|
||||||
|
workspaceId,
|
||||||
|
targetUserId,
|
||||||
|
updateRoleDto,
|
||||||
|
mockUser
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual(mockMember);
|
||||||
|
expect(service.updateMemberRole).toHaveBeenCalledWith(
|
||||||
|
workspaceId,
|
||||||
|
mockUser.id,
|
||||||
|
targetUserId,
|
||||||
|
updateRoleDto
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DELETE /api/workspaces/:id/members/:userId", () => {
|
||||||
|
it("should call service with workspace id, actor id, and target user id", async () => {
|
||||||
|
const workspaceId = "ws-1";
|
||||||
|
const targetUserId = "user-2";
|
||||||
|
mockWorkspacesService.removeMember.mockResolvedValueOnce(undefined);
|
||||||
|
|
||||||
|
await controller.removeMember(workspaceId, targetUserId, mockUser);
|
||||||
|
|
||||||
|
expect(service.removeMember).toHaveBeenCalledWith(workspaceId, mockUser.id, targetUserId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
85
apps/api/src/workspaces/workspaces.controller.ts
Normal file
85
apps/api/src/workspaces/workspaces.controller.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from "@nestjs/common";
|
||||||
|
import { WorkspacesService } from "./workspaces.service";
|
||||||
|
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||||
|
import { CurrentUser } from "../auth/decorators/current-user.decorator";
|
||||||
|
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||||
|
import { Permission, RequirePermission } from "../common/decorators";
|
||||||
|
import type { WorkspaceMember } from "@prisma/client";
|
||||||
|
import type { AuthenticatedUser } from "../common/types/user.types";
|
||||||
|
import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* User-scoped workspace operations.
|
||||||
|
*
|
||||||
|
* Intentionally does NOT use WorkspaceGuard — these routes operate across all
|
||||||
|
* workspaces the user belongs to, not within a single workspace context.
|
||||||
|
*/
|
||||||
|
@Controller("workspaces")
|
||||||
|
@UseGuards(AuthGuard)
|
||||||
|
export class WorkspacesController {
|
||||||
|
constructor(private readonly workspacesService: WorkspacesService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/workspaces
|
||||||
|
* Returns workspaces the authenticated user is a member of.
|
||||||
|
* Auto-provisions a default workspace if the user has none.
|
||||||
|
*/
|
||||||
|
@Get()
|
||||||
|
async getUserWorkspaces(@CurrentUser() user: AuthenticatedUser): Promise<WorkspaceResponseDto[]> {
|
||||||
|
return this.workspacesService.getUserWorkspaces(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/workspaces/:workspaceId/members
|
||||||
|
* Add a member to a workspace with the specified role.
|
||||||
|
* Requires: ADMIN role or higher.
|
||||||
|
*/
|
||||||
|
@Post(":workspaceId/members")
|
||||||
|
@UseGuards(WorkspaceGuard, PermissionGuard)
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||||
|
async addMember(
|
||||||
|
@Param("workspaceId") workspaceId: string,
|
||||||
|
@Body() addMemberDto: AddMemberDto,
|
||||||
|
@CurrentUser() user: AuthenticatedUser
|
||||||
|
): Promise<WorkspaceMember> {
|
||||||
|
return this.workspacesService.addMember(workspaceId, user.id, addMemberDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/workspaces/:workspaceId/members/:userId
|
||||||
|
* Change a member role in a workspace.
|
||||||
|
* Requires: ADMIN role or higher.
|
||||||
|
*/
|
||||||
|
@Patch(":workspaceId/members/:userId")
|
||||||
|
@UseGuards(WorkspaceGuard, PermissionGuard)
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||||
|
async updateMemberRole(
|
||||||
|
@Param("workspaceId") workspaceId: string,
|
||||||
|
@Param("userId") targetUserId: string,
|
||||||
|
@Body() updateMemberRoleDto: UpdateMemberRoleDto,
|
||||||
|
@CurrentUser() user: AuthenticatedUser
|
||||||
|
): Promise<WorkspaceMember> {
|
||||||
|
return this.workspacesService.updateMemberRole(
|
||||||
|
workspaceId,
|
||||||
|
user.id,
|
||||||
|
targetUserId,
|
||||||
|
updateMemberRoleDto
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/workspaces/:workspaceId/members/:userId
|
||||||
|
* Remove a member from a workspace.
|
||||||
|
* Requires: ADMIN role or higher.
|
||||||
|
*/
|
||||||
|
@Delete(":workspaceId/members/:userId")
|
||||||
|
@UseGuards(WorkspaceGuard, PermissionGuard)
|
||||||
|
@RequirePermission(Permission.WORKSPACE_ADMIN)
|
||||||
|
async removeMember(
|
||||||
|
@Param("workspaceId") workspaceId: string,
|
||||||
|
@Param("userId") targetUserId: string,
|
||||||
|
@CurrentUser() user: AuthenticatedUser
|
||||||
|
): Promise<void> {
|
||||||
|
await this.workspacesService.removeMember(workspaceId, user.id, targetUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
apps/api/src/workspaces/workspaces.module.ts
Normal file
13
apps/api/src/workspaces/workspaces.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { WorkspacesController } from "./workspaces.controller";
|
||||||
|
import { WorkspacesService } from "./workspaces.service";
|
||||||
|
import { PrismaModule } from "../prisma/prisma.module";
|
||||||
|
import { AuthModule } from "../auth/auth.module";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, AuthModule],
|
||||||
|
controllers: [WorkspacesController],
|
||||||
|
providers: [WorkspacesService],
|
||||||
|
exports: [WorkspacesService],
|
||||||
|
})
|
||||||
|
export class WorkspacesModule {}
|
||||||
516
apps/api/src/workspaces/workspaces.service.spec.ts
Normal file
516
apps/api/src/workspaces/workspaces.service.spec.ts
Normal file
@@ -0,0 +1,516 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
import { Test, TestingModule } from "@nestjs/testing";
|
||||||
|
import { WorkspacesService } from "./workspaces.service";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import { WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
|
||||||
|
describe("WorkspacesService", () => {
|
||||||
|
let service: WorkspacesService;
|
||||||
|
|
||||||
|
const mockUserId = "550e8400-e29b-41d4-a716-446655440001";
|
||||||
|
const mockAdminUserId = "550e8400-e29b-41d4-a716-446655440010";
|
||||||
|
const mockMemberUserId = "550e8400-e29b-41d4-a716-446655440011";
|
||||||
|
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440002";
|
||||||
|
|
||||||
|
const mockWorkspace = {
|
||||||
|
id: mockWorkspaceId,
|
||||||
|
name: "Test Workspace",
|
||||||
|
ownerId: mockUserId,
|
||||||
|
settings: {},
|
||||||
|
matrixRoomId: null,
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
updatedAt: new Date("2026-01-01"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockMembership = {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
workspace: {
|
||||||
|
id: mockWorkspaceId,
|
||||||
|
name: "Test Workspace",
|
||||||
|
ownerId: mockUserId,
|
||||||
|
createdAt: new Date("2026-01-01"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockPrismaService = {
|
||||||
|
workspaceMember: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
count: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
},
|
||||||
|
$transaction: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
|
providers: [
|
||||||
|
WorkspacesService,
|
||||||
|
{
|
||||||
|
provide: PrismaService,
|
||||||
|
useValue: mockPrismaService,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compile();
|
||||||
|
|
||||||
|
service = module.get<WorkspacesService>(WorkspacesService);
|
||||||
|
|
||||||
|
vi.clearAllMocks();
|
||||||
|
|
||||||
|
mockPrismaService.$transaction.mockImplementation(
|
||||||
|
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) =>
|
||||||
|
fn(mockPrismaService as unknown as typeof mockPrismaService)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUserWorkspaces", () => {
|
||||||
|
it("should return all workspaces user is a member of", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([mockMembership]);
|
||||||
|
|
||||||
|
const result = await service.getUserWorkspaces(mockUserId);
|
||||||
|
|
||||||
|
expect(result).toEqual([
|
||||||
|
{
|
||||||
|
id: mockWorkspaceId,
|
||||||
|
name: "Test Workspace",
|
||||||
|
ownerId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
createdAt: mockMembership.workspace.createdAt,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(mockPrismaService.workspaceMember.findMany).toHaveBeenCalledWith({
|
||||||
|
where: { userId: mockUserId },
|
||||||
|
include: {
|
||||||
|
workspace: {
|
||||||
|
select: { id: true, name: true, ownerId: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { joinedAt: "asc" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return multiple workspaces ordered by joinedAt", async () => {
|
||||||
|
const secondWorkspace = {
|
||||||
|
...mockMembership,
|
||||||
|
workspaceId: "ws-2",
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
joinedAt: new Date("2026-02-01"),
|
||||||
|
workspace: {
|
||||||
|
id: "ws-2",
|
||||||
|
name: "Second Workspace",
|
||||||
|
ownerId: "other-user",
|
||||||
|
createdAt: new Date("2026-02-01"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([
|
||||||
|
mockMembership,
|
||||||
|
secondWorkspace,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await service.getUserWorkspaces(mockUserId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2);
|
||||||
|
expect(result[0].id).toBe(mockWorkspaceId);
|
||||||
|
expect(result[1].id).toBe("ws-2");
|
||||||
|
expect(result[1].role).toBe(WorkspaceMemberRole.MEMBER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should auto-provision a default workspace when user has no memberships", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]);
|
||||||
|
mockPrismaService.$transaction.mockImplementationOnce(
|
||||||
|
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) => {
|
||||||
|
const txMock = {
|
||||||
|
workspaceMember: {
|
||||||
|
findFirst: vi.fn().mockResolvedValueOnce(null),
|
||||||
|
create: vi.fn().mockResolvedValueOnce({}),
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
create: vi.fn().mockResolvedValueOnce(mockWorkspace),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fn(txMock as unknown as typeof mockPrismaService);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getUserWorkspaces(mockUserId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe("Test Workspace");
|
||||||
|
expect(result[0].role).toBe(WorkspaceMemberRole.OWNER);
|
||||||
|
expect(mockPrismaService.$transaction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return existing workspace if one was created between initial check and transaction", async () => {
|
||||||
|
// Simulates a race condition: initial findMany returns [], but inside the
|
||||||
|
// transaction another request already created a workspace.
|
||||||
|
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]);
|
||||||
|
mockPrismaService.$transaction.mockImplementationOnce(
|
||||||
|
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) => {
|
||||||
|
const txMock = {
|
||||||
|
workspaceMember: {
|
||||||
|
findFirst: vi.fn().mockResolvedValueOnce(mockMembership),
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fn(txMock as unknown as typeof mockPrismaService);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await service.getUserWorkspaces(mockUserId);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe(mockWorkspaceId);
|
||||||
|
expect(result[0].name).toBe("Test Workspace");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create workspace with correct data during auto-provisioning", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
let capturedWorkspaceData: unknown;
|
||||||
|
let capturedMemberData: unknown;
|
||||||
|
|
||||||
|
mockPrismaService.$transaction.mockImplementationOnce(
|
||||||
|
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) => {
|
||||||
|
const txMock = {
|
||||||
|
workspaceMember: {
|
||||||
|
findFirst: vi.fn().mockResolvedValueOnce(null),
|
||||||
|
create: vi.fn().mockImplementation((args: unknown) => {
|
||||||
|
capturedMemberData = args;
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
create: vi.fn().mockImplementation((args: unknown) => {
|
||||||
|
capturedWorkspaceData = args;
|
||||||
|
return mockWorkspace;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fn(txMock as unknown as typeof mockPrismaService);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.getUserWorkspaces(mockUserId);
|
||||||
|
|
||||||
|
expect(capturedWorkspaceData).toEqual({
|
||||||
|
data: {
|
||||||
|
name: "My Workspace",
|
||||||
|
ownerId: mockUserId,
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(capturedMemberData).toEqual({
|
||||||
|
data: {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not auto-provision when user already has workspaces", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([mockMembership]);
|
||||||
|
|
||||||
|
await service.getUserWorkspaces(mockUserId);
|
||||||
|
|
||||||
|
expect(mockPrismaService.$transaction).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should propagate database errors", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findMany.mockRejectedValueOnce(
|
||||||
|
new Error("Database connection failed")
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.getUserWorkspaces(mockUserId)).rejects.toThrow(
|
||||||
|
"Database connection failed"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addMember", () => {
|
||||||
|
const addMemberDto = {
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("should add a new member to the workspace", async () => {
|
||||||
|
const createdMembership = {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
joinedAt: new Date("2026-02-02"),
|
||||||
|
};
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValueOnce({ id: mockMemberUserId });
|
||||||
|
mockPrismaService.workspaceMember.findUnique
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockAdminUserId,
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
mockPrismaService.workspaceMember.create.mockResolvedValueOnce(createdMembership);
|
||||||
|
|
||||||
|
const result = await service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto);
|
||||||
|
|
||||||
|
expect(result).toEqual(createdMembership);
|
||||||
|
expect(mockPrismaService.workspaceMember.create).toHaveBeenCalledWith({
|
||||||
|
data: {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when user does not exist", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockAdminUserId,
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
});
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto)
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ConflictException when user is already a member", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockAdminUserId,
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
});
|
||||||
|
mockPrismaService.user.findUnique.mockResolvedValueOnce({ id: mockMemberUserId });
|
||||||
|
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
joinedAt: new Date("2026-01-02"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.addMember(mockWorkspaceId, mockAdminUserId, addMemberDto)
|
||||||
|
).rejects.toThrow(ConflictException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException when admin tries to assign OWNER role", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findUnique.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockAdminUserId,
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.addMember(mockWorkspaceId, mockAdminUserId, {
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateMemberRole", () => {
|
||||||
|
it("should update a member role", async () => {
|
||||||
|
const updatedMembership = {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
joinedAt: new Date("2026-01-02"),
|
||||||
|
};
|
||||||
|
mockPrismaService.workspaceMember.findUnique
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
joinedAt: new Date("2026-01-02"),
|
||||||
|
});
|
||||||
|
mockPrismaService.workspaceMember.update.mockResolvedValueOnce(updatedMembership);
|
||||||
|
|
||||||
|
const result = await service.updateMemberRole(mockWorkspaceId, mockUserId, mockMemberUserId, {
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(updatedMembership);
|
||||||
|
expect(mockPrismaService.workspaceMember.update).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw NotFoundException when target member does not exist", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findUnique
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateMemberRole(mockWorkspaceId, mockUserId, mockMemberUserId, {
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException when sole owner attempts self-demotion", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findUnique
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
});
|
||||||
|
mockPrismaService.workspaceMember.count.mockResolvedValueOnce(1);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateMemberRole(mockWorkspaceId, mockUserId, mockUserId, {
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException when actor tries to change role of higher-ranked member", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findUnique
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockAdminUserId,
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.updateMemberRole(mockWorkspaceId, mockAdminUserId, mockUserId, {
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("removeMember", () => {
|
||||||
|
it("should remove a workspace member", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findUnique
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
joinedAt: new Date("2026-01-02"),
|
||||||
|
});
|
||||||
|
mockPrismaService.workspaceMember.delete.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
role: WorkspaceMemberRole.MEMBER,
|
||||||
|
joinedAt: new Date("2026-01-02"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.removeMember(mockWorkspaceId, mockUserId, mockMemberUserId);
|
||||||
|
|
||||||
|
expect(mockPrismaService.workspaceMember.delete).toHaveBeenCalledWith({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockMemberUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw BadRequestException when trying to remove the last owner", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findUnique
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
});
|
||||||
|
mockPrismaService.workspaceMember.count.mockResolvedValueOnce(1);
|
||||||
|
|
||||||
|
await expect(service.removeMember(mockWorkspaceId, mockUserId, mockUserId)).rejects.toThrow(
|
||||||
|
BadRequestException
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw ForbiddenException when admin attempts to remove an owner", async () => {
|
||||||
|
mockPrismaService.workspaceMember.findUnique
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockAdminUserId,
|
||||||
|
role: WorkspaceMemberRole.ADMIN,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
workspaceId: mockWorkspaceId,
|
||||||
|
userId: mockUserId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
joinedAt: new Date("2026-01-01"),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.removeMember(mockWorkspaceId, mockAdminUserId, mockUserId)
|
||||||
|
).rejects.toThrow(ForbiddenException);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
345
apps/api/src/workspaces/workspaces.service.ts
Normal file
345
apps/api/src/workspaces/workspaces.service.ts
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
Logger,
|
||||||
|
NotFoundException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { Prisma, WorkspaceMemberRole } from "@prisma/client";
|
||||||
|
import type { WorkspaceMember } from "@prisma/client";
|
||||||
|
import { PrismaService } from "../prisma/prisma.service";
|
||||||
|
import type { AddMemberDto, UpdateMemberRoleDto, WorkspaceResponseDto } from "./dto";
|
||||||
|
|
||||||
|
const WORKSPACE_ROLE_RANK: Record<WorkspaceMemberRole, number> = {
|
||||||
|
[WorkspaceMemberRole.GUEST]: 1,
|
||||||
|
[WorkspaceMemberRole.MEMBER]: 2,
|
||||||
|
[WorkspaceMemberRole.ADMIN]: 3,
|
||||||
|
[WorkspaceMemberRole.OWNER]: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorkspacesService {
|
||||||
|
private readonly logger = new Logger(WorkspacesService.name);
|
||||||
|
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all workspaces the user is a member of.
|
||||||
|
*
|
||||||
|
* Auto-provisioning: if the user has no workspace memberships (e.g. fresh
|
||||||
|
* signup via BetterAuth), a default workspace is created atomically and
|
||||||
|
* returned. This is the only call site for workspace bootstrapping.
|
||||||
|
*/
|
||||||
|
async getUserWorkspaces(userId: string): Promise<WorkspaceResponseDto[]> {
|
||||||
|
const memberships = await this.prisma.workspaceMember.findMany({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
workspace: {
|
||||||
|
select: { id: true, name: true, ownerId: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { joinedAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (memberships.length > 0) {
|
||||||
|
return memberships.map((m) => ({
|
||||||
|
id: m.workspace.id,
|
||||||
|
name: m.workspace.name,
|
||||||
|
ownerId: m.workspace.ownerId,
|
||||||
|
role: m.role,
|
||||||
|
createdAt: m.workspace.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-provision a default workspace for new users.
|
||||||
|
// Re-query inside the transaction to guard against concurrent requests
|
||||||
|
// both seeing zero memberships and creating duplicate workspaces.
|
||||||
|
this.logger.log(`Auto-provisioning default workspace for user ${userId}`);
|
||||||
|
|
||||||
|
const workspace = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const existing = await tx.workspaceMember.findFirst({
|
||||||
|
where: { userId },
|
||||||
|
include: {
|
||||||
|
workspace: {
|
||||||
|
select: { id: true, name: true, ownerId: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
return { ...existing.workspace, alreadyExisted: true as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await tx.workspace.create({
|
||||||
|
data: {
|
||||||
|
name: "My Workspace",
|
||||||
|
ownerId: userId,
|
||||||
|
settings: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.workspaceMember.create({
|
||||||
|
data: {
|
||||||
|
workspaceId: created.id,
|
||||||
|
userId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { ...created, alreadyExisted: false as const };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (workspace.alreadyExisted) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: workspace.id,
|
||||||
|
name: workspace.name,
|
||||||
|
ownerId: workspace.ownerId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
createdAt: workspace.createdAt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: workspace.id,
|
||||||
|
name: workspace.name,
|
||||||
|
ownerId: workspace.ownerId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
createdAt: workspace.createdAt,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a member to a workspace.
|
||||||
|
*/
|
||||||
|
async addMember(
|
||||||
|
workspaceId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
addMemberDto: AddMemberDto
|
||||||
|
): Promise<WorkspaceMember> {
|
||||||
|
const actorMembership = await this.prisma.workspaceMember.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId: actorUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!actorMembership) {
|
||||||
|
throw new ForbiddenException("You are not a member of this workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertCanAssignRole(actorMembership.role, addMemberDto.role);
|
||||||
|
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: addMemberDto.userId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException(`User with ID ${addMemberDto.userId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingMembership = await this.prisma.workspaceMember.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId: addMemberDto.userId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
workspaceId: true,
|
||||||
|
userId: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingMembership) {
|
||||||
|
throw new ConflictException("User is already a member of this workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await this.prisma.workspaceMember.create({
|
||||||
|
data: {
|
||||||
|
workspaceId,
|
||||||
|
userId: addMemberDto.userId,
|
||||||
|
role: addMemberDto.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isUniqueConstraintError(error)) {
|
||||||
|
throw new ConflictException("User is already a member of this workspace");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the role of an existing workspace member.
|
||||||
|
*/
|
||||||
|
async updateMemberRole(
|
||||||
|
workspaceId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
targetUserId: string,
|
||||||
|
updateMemberRoleDto: UpdateMemberRoleDto
|
||||||
|
): Promise<WorkspaceMember> {
|
||||||
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
const actorMembership = await tx.workspaceMember.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId: actorUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!actorMembership) {
|
||||||
|
throw new ForbiddenException("You are not a member of this workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMembership = await tx.workspaceMember.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId: targetUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetMembership) {
|
||||||
|
throw new NotFoundException(`User ${targetUserId} is not a member of this workspace`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertCanManageTargetMember(actorMembership.role, targetMembership.role);
|
||||||
|
this.assertCanAssignRole(actorMembership.role, updateMemberRoleDto.role);
|
||||||
|
|
||||||
|
if (targetMembership.role === WorkspaceMemberRole.OWNER) {
|
||||||
|
const isDemotion = updateMemberRoleDto.role !== WorkspaceMemberRole.OWNER;
|
||||||
|
if (isDemotion) {
|
||||||
|
const ownerCount = await tx.workspaceMember.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (ownerCount <= 1) {
|
||||||
|
if (actorUserId === targetUserId) {
|
||||||
|
throw new BadRequestException("Cannot self-demote if you are the sole owner");
|
||||||
|
}
|
||||||
|
throw new BadRequestException("Cannot remove the last owner from a workspace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.workspaceMember.update({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId: targetUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
role: updateMemberRoleDto.role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a member from a workspace.
|
||||||
|
*/
|
||||||
|
async removeMember(
|
||||||
|
workspaceId: string,
|
||||||
|
actorUserId: string,
|
||||||
|
targetUserId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
const actorMembership = await tx.workspaceMember.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId: actorUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!actorMembership) {
|
||||||
|
throw new ForbiddenException("You are not a member of this workspace");
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetMembership = await tx.workspaceMember.findUnique({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId: targetUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!targetMembership) {
|
||||||
|
throw new NotFoundException(`User ${targetUserId} is not a member of this workspace`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertCanManageTargetMember(actorMembership.role, targetMembership.role);
|
||||||
|
|
||||||
|
if (targetMembership.role === WorkspaceMemberRole.OWNER) {
|
||||||
|
const ownerCount = await tx.workspaceMember.count({
|
||||||
|
where: {
|
||||||
|
workspaceId,
|
||||||
|
role: WorkspaceMemberRole.OWNER,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (ownerCount <= 1) {
|
||||||
|
throw new BadRequestException("Cannot remove the last owner from a workspace");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.workspaceMember.delete({
|
||||||
|
where: {
|
||||||
|
workspaceId_userId: {
|
||||||
|
workspaceId,
|
||||||
|
userId: targetUserId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanAssignRole(
|
||||||
|
actorRole: WorkspaceMemberRole,
|
||||||
|
requestedRole: WorkspaceMemberRole
|
||||||
|
): void {
|
||||||
|
if (WORKSPACE_ROLE_RANK[actorRole] < WORKSPACE_ROLE_RANK[requestedRole]) {
|
||||||
|
throw new ForbiddenException("You cannot assign a role higher than your own");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertCanManageTargetMember(
|
||||||
|
actorRole: WorkspaceMemberRole,
|
||||||
|
targetRole: WorkspaceMemberRole
|
||||||
|
): void {
|
||||||
|
if (WORKSPACE_ROLE_RANK[actorRole] < WORKSPACE_ROLE_RANK[targetRole]) {
|
||||||
|
throw new ForbiddenException("You cannot manage a member with a higher role");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
|
||||||
|
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/orchestrator",
|
"name": "@mosaic/orchestrator",
|
||||||
"version": "0.0.6",
|
"version": "0.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "nest start --watch",
|
"dev": "nest start --watch",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@mosaic/web",
|
"name": "@mosaic/web",
|
||||||
"version": "0.0.1",
|
"version": "0.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
@@ -33,6 +33,9 @@
|
|||||||
"@tiptap/react": "^3.20.0",
|
"@tiptap/react": "^3.20.0",
|
||||||
"@tiptap/starter-kit": "^3.20.0",
|
"@tiptap/starter-kit": "^3.20.0",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"@xyflow/react": "^12.5.3",
|
"@xyflow/react": "^12.5.3",
|
||||||
"better-auth": "^1.4.17",
|
"better-auth": "^1.4.17",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
@@ -326,7 +326,7 @@ function LoginPageContent(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 flex justify-center">
|
<div className="mt-6 flex justify-center">
|
||||||
<AuthStatusPill label="Mosaic v0.1" tone="neutral" />
|
<AuthStatusPill label="Mosaic v0.0.20" tone="neutral" />
|
||||||
</div>
|
</div>
|
||||||
</AuthCard>
|
</AuthCard>
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export default function ProfilePage(): ReactElement {
|
|||||||
setPrefsError(null);
|
setPrefsError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await apiGet<UserPreferences>("/users/me/preferences");
|
const data = await apiGet<UserPreferences>("/api/users/me/preferences");
|
||||||
setPreferences(data);
|
setPreferences(data);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = err instanceof Error ? err.message : "Could not load preferences";
|
const message = err instanceof Error ? err.message : "Could not load preferences";
|
||||||
@@ -265,23 +265,8 @@ export default function ProfilePage(): ReactElement {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{user?.workspaceRole && (
|
{/* Workspace role badge — placeholder until workspace context API
|
||||||
<span
|
provides role data via GET /api/workspaces */}
|
||||||
style={{
|
|
||||||
display: "inline-block",
|
|
||||||
marginTop: 8,
|
|
||||||
padding: "3px 10px",
|
|
||||||
borderRadius: "var(--r)",
|
|
||||||
background: "rgba(47, 128, 255, 0.1)",
|
|
||||||
color: "var(--ms-blue-400)",
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
textTransform: "capitalize",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{user.workspaceRole}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ export default function AppearanceSettingsPage(): ReactElement {
|
|||||||
setLocalTheme(themeId);
|
setLocalTheme(themeId);
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await apiPatch("/users/me/preferences", { theme: themeId });
|
await apiPatch("/api/users/me/preferences", { theme: themeId });
|
||||||
} catch {
|
} catch {
|
||||||
// Theme is still applied locally even if API save fails
|
// Theme is still applied locally even if API save fails
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
|
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
const ACTIVITY_ACTIONS = [
|
const ACTIVITY_ACTIONS = [
|
||||||
{ value: "CREDENTIAL_CREATED", label: "Created" },
|
{ value: "CREDENTIAL_CREATED", label: "Created" },
|
||||||
@@ -39,17 +40,17 @@ export default function CredentialAuditPage(): React.ReactElement {
|
|||||||
const [filters, setFilters] = useState<FilterState>({});
|
const [filters, setFilters] = useState<FilterState>({});
|
||||||
const [hasFilters, setHasFilters] = useState(false);
|
const [hasFilters, setHasFilters] = useState(false);
|
||||||
|
|
||||||
// TODO: Get workspace ID from context/auth
|
const workspaceId = useWorkspaceId();
|
||||||
const workspaceId = "default-workspace-id"; // Placeholder
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadLogs();
|
if (!workspaceId) return;
|
||||||
}, [page, filters]);
|
void loadLogs(workspaceId);
|
||||||
|
}, [workspaceId, page, filters]);
|
||||||
|
|
||||||
async function loadLogs(): Promise<void> {
|
async function loadLogs(wsId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetchCredentialAuditLog(workspaceId, {
|
const response = await fetchCredentialAuditLog(wsId, {
|
||||||
...filters,
|
...filters,
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,383 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect, type SyntheticEvent } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
import type { Domain } from "@mosaic/shared";
|
import type { Domain } from "@mosaic/shared";
|
||||||
import { DomainList } from "@/components/domains/DomainList";
|
import { DomainList } from "@/components/domains/DomainList";
|
||||||
import { fetchDomains, deleteDomain } from "@/lib/api/domains";
|
import { fetchDomains, createDomain, deleteDomain } from "@/lib/api/domains";
|
||||||
|
import type { CreateDomainDto } from "@/lib/api/domains";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
|
|
||||||
export default function DomainsPage(): React.ReactElement {
|
/* ---------------------------------------------------------------------------
|
||||||
|
Slug generation helper
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function generateSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, "")
|
||||||
|
.replace(/\s+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.slice(0, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Create Domain Dialog
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
interface CreateDomainDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSubmit: (data: CreateDomainDto) => Promise<void>;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateDomainDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSubmit,
|
||||||
|
isSubmitting,
|
||||||
|
}: CreateDomainDialogProps): ReactElement | null {
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [slug, setSlug] = useState("");
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
|
const [description, setDescription] = useState("");
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
function resetForm(): void {
|
||||||
|
setName("");
|
||||||
|
setSlug("");
|
||||||
|
setSlugTouched(false);
|
||||||
|
setDescription("");
|
||||||
|
setFormError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNameChange(value: string): void {
|
||||||
|
setName(value);
|
||||||
|
if (!slugTouched) {
|
||||||
|
setSlug(generateSlug(value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlugChange(value: string): void {
|
||||||
|
setSlugTouched(true);
|
||||||
|
setSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
setFormError("Domain name is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedSlug = slug.trim();
|
||||||
|
if (!trimmedSlug) {
|
||||||
|
setFormError("Slug is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^[a-z0-9-]+$/.test(trimmedSlug)) {
|
||||||
|
setFormError("Slug must contain only lowercase letters, numbers, and hyphens.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload: CreateDomainDto = { name: trimmedName, slug: trimmedSlug };
|
||||||
|
const trimmedDesc = description.trim();
|
||||||
|
if (trimmedDesc) {
|
||||||
|
payload.description = trimmedDesc;
|
||||||
|
}
|
||||||
|
await onSubmit(payload);
|
||||||
|
resetForm();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setFormError(err instanceof Error ? err.message : "Failed to create domain.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="create-domain-title"
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
resetForm();
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Dialog */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
background: "var(--surface, #fff)",
|
||||||
|
borderRadius: "8px",
|
||||||
|
border: "1px solid var(--border, #e5e7eb)",
|
||||||
|
padding: 24,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 480,
|
||||||
|
zIndex: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
id="create-domain-title"
|
||||||
|
style={{
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: "var(--text, #111)",
|
||||||
|
margin: "0 0 8px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New Domain
|
||||||
|
</h2>
|
||||||
|
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
|
||||||
|
Domains help you organize tasks, projects, and events by life area.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
void handleSubmit(e);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Name */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label
|
||||||
|
htmlFor="domain-name"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--text-2, #374151)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Name <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="domain-name"
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleNameChange(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="e.g. Personal Finance"
|
||||||
|
maxLength={255}
|
||||||
|
autoFocus
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg, #f9fafb)",
|
||||||
|
border: "1px solid var(--border, #d1d5db)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text, #111)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Slug */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label
|
||||||
|
htmlFor="domain-slug"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--text-2, #374151)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Slug <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="domain-slug"
|
||||||
|
type="text"
|
||||||
|
value={slug}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleSlugChange(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="e.g. personal-finance"
|
||||||
|
maxLength={100}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg, #f9fafb)",
|
||||||
|
border: "1px solid var(--border, #d1d5db)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text, #111)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
outline: "none",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
fontFamily: "var(--mono, monospace)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--muted, #6b7280)",
|
||||||
|
margin: "4px 0 0",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Lowercase letters, numbers, and hyphens only.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<label
|
||||||
|
htmlFor="domain-description"
|
||||||
|
style={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: 6,
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
color: "var(--text-2, #374151)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="domain-description"
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => {
|
||||||
|
setDescription(e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="A brief summary of this domain..."
|
||||||
|
rows={3}
|
||||||
|
maxLength={10000}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "8px 12px",
|
||||||
|
background: "var(--bg, #f9fafb)",
|
||||||
|
border: "1px solid var(--border, #d1d5db)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text, #111)",
|
||||||
|
fontSize: "0.9rem",
|
||||||
|
outline: "none",
|
||||||
|
resize: "vertical",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form error */}
|
||||||
|
{formError !== null && (
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "var(--danger, #ef4444)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
margin: "0 0 12px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Buttons */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
resetForm();
|
||||||
|
onOpenChange(false);
|
||||||
|
}}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid var(--border, #d1d5db)",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "var(--text-2, #374151)",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting || !name.trim() || !slug.trim()}
|
||||||
|
style={{
|
||||||
|
padding: "8px 16px",
|
||||||
|
background: "var(--primary, #111827)",
|
||||||
|
border: "none",
|
||||||
|
borderRadius: "6px",
|
||||||
|
color: "#fff",
|
||||||
|
fontSize: "0.85rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
cursor: isSubmitting || !name.trim() || !slug.trim() ? "not-allowed" : "pointer",
|
||||||
|
opacity: isSubmitting || !name.trim() || !slug.trim() ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isSubmitting ? "Creating..." : "Create Domain"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------------
|
||||||
|
Domains Page
|
||||||
|
--------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export default function DomainsPage(): ReactElement {
|
||||||
|
const workspaceId = useWorkspaceId();
|
||||||
const [domains, setDomains] = useState<Domain[]>([]);
|
const [domains, setDomains] = useState<Domain[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Create dialog state
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!workspaceId) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void loadDomains();
|
void loadDomains();
|
||||||
}, []);
|
}, [workspaceId]);
|
||||||
|
|
||||||
async function loadDomains(): Promise<void> {
|
async function loadDomains(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await fetchDomains();
|
const response = await fetchDomains(undefined, workspaceId ?? undefined);
|
||||||
setDomains(response.data);
|
setDomains(response.data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -27,9 +387,8 @@ export default function DomainsPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleEdit(domain: Domain): void {
|
function handleEdit(_domain: Domain): void {
|
||||||
// TODO: Open edit modal/form
|
// TODO: Open edit modal/form
|
||||||
console.log("Edit domain:", domain);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(domain: Domain): Promise<void> {
|
async function handleDelete(domain: Domain): Promise<void> {
|
||||||
@@ -38,13 +397,26 @@ export default function DomainsPage(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteDomain(domain.id);
|
await deleteDomain(domain.id, workspaceId ?? undefined);
|
||||||
await loadDomains();
|
await loadDomains();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to delete domain");
|
setError(err instanceof Error ? err.message : "Failed to delete domain");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCreate(data: CreateDomainDto): Promise<void> {
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
await createDomain(data, workspaceId ?? undefined);
|
||||||
|
setCreateOpen(false);
|
||||||
|
await loadDomains();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to create domain.");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto p-6">
|
<div className="max-w-6xl mx-auto p-6">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
@@ -60,7 +432,7 @@ export default function DomainsPage(): React.ReactElement {
|
|||||||
<button
|
<button
|
||||||
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
|
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
console.log("TODO: Open create modal");
|
setCreateOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Create Domain
|
Create Domain
|
||||||
@@ -73,6 +445,13 @@ export default function DomainsPage(): React.ReactElement {
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CreateDomainDialog
|
||||||
|
open={createOpen}
|
||||||
|
onOpenChange={setCreateOpen}
|
||||||
|
onSubmit={handleCreate}
|
||||||
|
isSubmitting={isCreating}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,12 @@ const mockMembers: WorkspaceMemberWithUser[] = [
|
|||||||
image: null,
|
image: null,
|
||||||
authProviderId: null,
|
authProviderId: null,
|
||||||
preferences: {},
|
preferences: {},
|
||||||
|
deactivatedAt: null,
|
||||||
|
isLocalAuth: false,
|
||||||
|
passwordHash: null,
|
||||||
|
invitedBy: null,
|
||||||
|
invitationToken: null,
|
||||||
|
invitedAt: null,
|
||||||
createdAt: new Date("2024-01-15"),
|
createdAt: new Date("2024-01-15"),
|
||||||
updatedAt: new Date("2024-01-15"),
|
updatedAt: new Date("2024-01-15"),
|
||||||
},
|
},
|
||||||
@@ -56,6 +62,12 @@ const mockMembers: WorkspaceMemberWithUser[] = [
|
|||||||
image: null,
|
image: null,
|
||||||
authProviderId: null,
|
authProviderId: null,
|
||||||
preferences: {},
|
preferences: {},
|
||||||
|
deactivatedAt: null,
|
||||||
|
isLocalAuth: false,
|
||||||
|
passwordHash: null,
|
||||||
|
invitedBy: null,
|
||||||
|
invitationToken: null,
|
||||||
|
invitedAt: null,
|
||||||
createdAt: new Date("2024-01-16"),
|
createdAt: new Date("2024-01-16"),
|
||||||
updatedAt: new Date("2024-01-16"),
|
updatedAt: new Date("2024-01-16"),
|
||||||
},
|
},
|
||||||
@@ -73,6 +85,12 @@ const mockMembers: WorkspaceMemberWithUser[] = [
|
|||||||
image: null,
|
image: null,
|
||||||
authProviderId: null,
|
authProviderId: null,
|
||||||
preferences: {},
|
preferences: {},
|
||||||
|
deactivatedAt: null,
|
||||||
|
isLocalAuth: false,
|
||||||
|
passwordHash: null,
|
||||||
|
invitedBy: null,
|
||||||
|
invitationToken: null,
|
||||||
|
invitedAt: null,
|
||||||
createdAt: new Date("2024-01-17"),
|
createdAt: new Date("2024-01-17"),
|
||||||
updatedAt: new Date("2024-01-17"),
|
updatedAt: new Date("2024-01-17"),
|
||||||
},
|
},
|
||||||
|
|||||||
63
apps/web/src/app/(authenticated)/terminal/page.tsx
Normal file
63
apps/web/src/app/(authenticated)/terminal/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Terminal page — dedicated full-screen terminal route at /terminal.
|
||||||
|
*
|
||||||
|
* Renders the TerminalPanel component filling the available content area.
|
||||||
|
* The panel is always open on this page; there is no close action since
|
||||||
|
* the user navigates away using the sidebar instead.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { TerminalPanel } from "@/components/terminal";
|
||||||
|
import { getAccessToken } from "@/lib/auth-client";
|
||||||
|
|
||||||
|
export default function TerminalPage(): ReactElement {
|
||||||
|
const [token, setToken] = useState<string>("");
|
||||||
|
|
||||||
|
// Resolve the access token once on mount. The WebSocket connection inside
|
||||||
|
// TerminalPanel uses this token for authentication.
|
||||||
|
useEffect((): void => {
|
||||||
|
getAccessToken()
|
||||||
|
.then((t) => {
|
||||||
|
setToken(t ?? "");
|
||||||
|
})
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
console.error("[TerminalPage] Failed to retrieve access token:", err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Override TerminalPanel inline height so it fills the page */}
|
||||||
|
<style>{`
|
||||||
|
.terminal-page-panel {
|
||||||
|
height: 100% !important;
|
||||||
|
border-top: none !important;
|
||||||
|
flex: 1 !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
aria-label="Terminal"
|
||||||
|
>
|
||||||
|
<TerminalPanel
|
||||||
|
open={true}
|
||||||
|
onClose={(): void => {
|
||||||
|
/* No-op: on the dedicated terminal page the panel is always open.
|
||||||
|
Users navigate away using the sidebar rather than closing the panel. */
|
||||||
|
}}
|
||||||
|
token={token}
|
||||||
|
className="terminal-page-panel"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 539 B |
@@ -11,6 +11,9 @@ export const dynamic = "force-dynamic";
|
|||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Mosaic Stack",
|
title: "Mosaic Stack",
|
||||||
description: "Mosaic Stack Web Application",
|
description: "Mosaic Stack Web Application",
|
||||||
|
icons: {
|
||||||
|
icon: "/favicon.ico",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const outfit = Outfit({
|
const outfit = Outfit({
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ const mockAvailableUsers: User[] = [
|
|||||||
image: null,
|
image: null,
|
||||||
authProviderId: null,
|
authProviderId: null,
|
||||||
preferences: {},
|
preferences: {},
|
||||||
|
deactivatedAt: null,
|
||||||
|
isLocalAuth: false,
|
||||||
|
passwordHash: null,
|
||||||
|
invitedBy: null,
|
||||||
|
invitationToken: null,
|
||||||
|
invitedAt: null,
|
||||||
createdAt: new Date("2026-01-17"),
|
createdAt: new Date("2026-01-17"),
|
||||||
updatedAt: new Date("2026-01-17"),
|
updatedAt: new Date("2026-01-17"),
|
||||||
},
|
},
|
||||||
@@ -32,6 +38,12 @@ const mockAvailableUsers: User[] = [
|
|||||||
image: null,
|
image: null,
|
||||||
authProviderId: null,
|
authProviderId: null,
|
||||||
preferences: {},
|
preferences: {},
|
||||||
|
deactivatedAt: null,
|
||||||
|
isLocalAuth: false,
|
||||||
|
passwordHash: null,
|
||||||
|
invitedBy: null,
|
||||||
|
invitationToken: null,
|
||||||
|
invitedAt: null,
|
||||||
createdAt: new Date("2026-01-18"),
|
createdAt: new Date("2026-01-18"),
|
||||||
updatedAt: new Date("2026-01-18"),
|
updatedAt: new Date("2026-01-18"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { createRef } from "react";
|
import { createRef } from "react";
|
||||||
import { render } from "@testing-library/react";
|
import { render, fireEvent, waitFor } from "@testing-library/react";
|
||||||
import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
|
import { describe, it, expect, beforeEach, vi, afterEach, type MockedFunction } from "vitest";
|
||||||
import { Chat, type ChatRef } from "./Chat";
|
import { Chat, type ChatRef } from "./Chat";
|
||||||
import * as useChatModule from "@/hooks/useChat";
|
import * as useChatModule from "@/hooks/useChat";
|
||||||
import * as useWebSocketModule from "@/hooks/useWebSocket";
|
import * as useWebSocketModule from "@/hooks/useWebSocket";
|
||||||
import * as authModule from "@/lib/auth/auth-context";
|
import * as authModule from "@/lib/auth/auth-context";
|
||||||
|
import * as orchestratorModule from "@/hooks/useOrchestratorCommands";
|
||||||
|
|
||||||
// Mock scrollIntoView (not available in JSDOM)
|
// Mock scrollIntoView (not available in JSDOM)
|
||||||
Element.prototype.scrollIntoView = vi.fn();
|
Element.prototype.scrollIntoView = vi.fn();
|
||||||
@@ -39,10 +40,28 @@ vi.mock("./ChatInput", () => ({
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
inputRef: React.RefObject<HTMLTextAreaElement | null>;
|
||||||
}): React.ReactElement => (
|
}): React.ReactElement => (
|
||||||
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
<>
|
||||||
Send
|
<button data-testid="chat-input" onClick={(): void => void onSend("test message")}>
|
||||||
</button>
|
Send
|
||||||
|
</button>
|
||||||
|
<button data-testid="chat-input-command" onClick={(): void => void onSend("/status")}>
|
||||||
|
Send Command
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
|
DEFAULT_TEMPERATURE: 0.7,
|
||||||
|
DEFAULT_MAX_TOKENS: 4096,
|
||||||
|
DEFAULT_MODEL: "llama3.2",
|
||||||
|
AVAILABLE_MODELS: [
|
||||||
|
{ id: "llama3.2", label: "Llama 3.2" },
|
||||||
|
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
|
||||||
|
{ id: "gpt-4o", label: "GPT-4o" },
|
||||||
|
{ id: "deepseek-r1", label: "DeepSeek R1" },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useOrchestratorCommands", () => ({
|
||||||
|
useOrchestratorCommands: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockUseAuth = authModule.useAuth as MockedFunction<typeof authModule.useAuth>;
|
const mockUseAuth = authModule.useAuth as MockedFunction<typeof authModule.useAuth>;
|
||||||
@@ -50,6 +69,9 @@ const mockUseChat = useChatModule.useChat as MockedFunction<typeof useChatModule
|
|||||||
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
|
const mockUseWebSocket = useWebSocketModule.useWebSocket as MockedFunction<
|
||||||
typeof useWebSocketModule.useWebSocket
|
typeof useWebSocketModule.useWebSocket
|
||||||
>;
|
>;
|
||||||
|
const mockUseOrchestratorCommands = orchestratorModule.useOrchestratorCommands as MockedFunction<
|
||||||
|
typeof orchestratorModule.useOrchestratorCommands
|
||||||
|
>;
|
||||||
|
|
||||||
function createMockUseChatReturn(
|
function createMockUseChatReturn(
|
||||||
overrides: Partial<useChatModule.UseChatReturn> = {}
|
overrides: Partial<useChatModule.UseChatReturn> = {}
|
||||||
@@ -98,6 +120,12 @@ describe("Chat", () => {
|
|||||||
socket: null,
|
socket: null,
|
||||||
connectionError: null,
|
connectionError: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Default: no commands intercepted
|
||||||
|
mockUseOrchestratorCommands.mockReturnValue({
|
||||||
|
isCommand: vi.fn().mockReturnValue(false),
|
||||||
|
executeCommand: vi.fn().mockResolvedValue(null),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -151,4 +179,105 @@ describe("Chat", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("orchestrator command routing", () => {
|
||||||
|
it("routes command messages through orchestrator instead of LLM", async () => {
|
||||||
|
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockSetMessages = vi.fn();
|
||||||
|
const mockExecuteCommand = vi.fn().mockResolvedValue({
|
||||||
|
id: "orch-123",
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: "**Orchestrator Status**\n\n| Field | Value |\n|---|---|\n| Status | **Ready** |",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseChat.mockReturnValue(
|
||||||
|
createMockUseChatReturn({
|
||||||
|
sendMessage: mockSendMessage,
|
||||||
|
setMessages: mockSetMessages,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
mockUseOrchestratorCommands.mockReturnValue({
|
||||||
|
isCommand: (content: string) => content.trim().startsWith("/"),
|
||||||
|
executeCommand: mockExecuteCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(<Chat />);
|
||||||
|
|
||||||
|
const commandButton = getByTestId("chat-input-command");
|
||||||
|
fireEvent.click(commandButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
// executeCommand should have been called with the slash command
|
||||||
|
expect(mockExecuteCommand).toHaveBeenCalledWith("/status");
|
||||||
|
});
|
||||||
|
|
||||||
|
// sendMessage should NOT have been called
|
||||||
|
expect(mockSendMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// setMessages should have been called to add user and assistant messages
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetMessages).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call orchestrator for regular messages", async () => {
|
||||||
|
const mockSendMessage = vi.fn().mockResolvedValue(undefined);
|
||||||
|
const mockExecuteCommand = vi.fn().mockResolvedValue(null);
|
||||||
|
|
||||||
|
mockUseChat.mockReturnValue(createMockUseChatReturn({ sendMessage: mockSendMessage }));
|
||||||
|
|
||||||
|
mockUseOrchestratorCommands.mockReturnValue({
|
||||||
|
isCommand: vi.fn().mockReturnValue(false),
|
||||||
|
executeCommand: mockExecuteCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(<Chat />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId("chat-input"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSendMessage).toHaveBeenCalledWith("test message");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockExecuteCommand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still adds user message to chat for commands", async () => {
|
||||||
|
const mockSetMessages = vi.fn();
|
||||||
|
const mockExecuteCommand = vi.fn().mockResolvedValue({
|
||||||
|
id: "orch-456",
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: "Help content",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
mockUseChat.mockReturnValue(createMockUseChatReturn({ setMessages: mockSetMessages }));
|
||||||
|
|
||||||
|
mockUseOrchestratorCommands.mockReturnValue({
|
||||||
|
isCommand: (content: string) => content.trim().startsWith("/"),
|
||||||
|
executeCommand: mockExecuteCommand,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { getByTestId } = render(<Chat />);
|
||||||
|
|
||||||
|
fireEvent.click(getByTestId("chat-input-command"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSetMessages).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
// First setMessages call should add the user message
|
||||||
|
const firstCall = mockSetMessages.mock.calls[0];
|
||||||
|
if (!firstCall) throw new Error("Expected setMessages to have been called");
|
||||||
|
const updater = firstCall[0] as (prev: useChatModule.Message[]) => useChatModule.Message[];
|
||||||
|
const result = updater([]);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0]).toMatchObject({
|
||||||
|
role: "user",
|
||||||
|
content: "/status",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,9 +3,12 @@
|
|||||||
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useImperativeHandle, forwardRef, useState } from "react";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { useChat } from "@/hooks/useChat";
|
import { useChat } from "@/hooks/useChat";
|
||||||
|
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
|
||||||
import { useWebSocket } from "@/hooks/useWebSocket";
|
import { useWebSocket } from "@/hooks/useWebSocket";
|
||||||
|
import { useWorkspaceId } from "@/lib/hooks";
|
||||||
import { MessageList } from "./MessageList";
|
import { MessageList } from "./MessageList";
|
||||||
import { ChatInput } from "./ChatInput";
|
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
|
||||||
|
import { ChatEmptyState } from "./ChatEmptyState";
|
||||||
import type { Message } from "@/hooks/useChat";
|
import type { Message } from "@/hooks/useChat";
|
||||||
|
|
||||||
export interface ChatRef {
|
export interface ChatRef {
|
||||||
@@ -59,6 +62,14 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
const { user, isLoading: authLoading } = useAuth();
|
const { user, isLoading: authLoading } = useAuth();
|
||||||
|
|
||||||
|
// Model and params state — initialized from ChatInput's persisted values
|
||||||
|
const [selectedModel, setSelectedModel] = useState<ModelId>("llama3.2");
|
||||||
|
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
||||||
|
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
||||||
|
|
||||||
|
// Suggestion fill value: controls ChatInput's textarea content
|
||||||
|
const [suggestionValue, setSuggestionValue] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
isLoading: isChatLoading,
|
isLoading: isChatLoading,
|
||||||
@@ -70,13 +81,22 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
abortStream,
|
abortStream,
|
||||||
loadConversation,
|
loadConversation,
|
||||||
startNewConversation,
|
startNewConversation,
|
||||||
|
setMessages,
|
||||||
clearError,
|
clearError,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
model: "llama3.2",
|
model: selectedModel,
|
||||||
|
temperature,
|
||||||
|
maxTokens,
|
||||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
// Read workspace ID from localStorage (set by auth-context after session check).
|
||||||
|
// Cookie-based auth (withCredentials) handles authentication, so no explicit
|
||||||
|
// token is needed here — pass an empty string as the token placeholder.
|
||||||
|
const workspaceId = useWorkspaceId() ?? "";
|
||||||
|
const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {});
|
||||||
|
|
||||||
|
const { isCommand, executeCommand } = useOrchestratorCommands();
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -88,6 +108,11 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
const streamingMessageId =
|
const streamingMessageId =
|
||||||
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
|
isStreaming && messages.length > 0 ? messages[messages.length - 1]?.id : undefined;
|
||||||
|
|
||||||
|
// Whether the conversation is empty (only welcome message or no messages)
|
||||||
|
const isEmptyConversation =
|
||||||
|
messages.length === 0 ||
|
||||||
|
(messages.length === 1 && messages[0]?.id === "welcome" && !isChatLoading && !isStreaming);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(ref, () => ({
|
||||||
loadConversation: async (cId: string): Promise<void> => {
|
loadConversation: async (cId: string): Promise<void> => {
|
||||||
await loadConversation(cId);
|
await loadConversation(cId);
|
||||||
@@ -122,16 +147,29 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent): void => {
|
const handleKeyDown = (e: KeyboardEvent): void => {
|
||||||
|
// Cmd/Ctrl + / : Focus input
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
if ((e.ctrlKey || e.metaKey) && e.key === "/") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
inputRef.current?.focus();
|
inputRef.current?.focus();
|
||||||
}
|
}
|
||||||
|
// Cmd/Ctrl + N : Start new conversation
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === "n" || e.key === "N")) {
|
||||||
|
e.preventDefault();
|
||||||
|
startNewConversation(null);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
|
// Cmd/Ctrl + L : Clear / start new conversation
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === "l" || e.key === "L")) {
|
||||||
|
e.preventDefault();
|
||||||
|
startNewConversation(null);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener("keydown", handleKeyDown);
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
return (): void => {
|
return (): void => {
|
||||||
document.removeEventListener("keydown", handleKeyDown);
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [startNewConversation]);
|
||||||
|
|
||||||
// Show loading quips only during non-streaming load (initial fetch wait)
|
// Show loading quips only during non-streaming load (initial fetch wait)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -163,11 +201,37 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
|
|
||||||
const handleSendMessage = useCallback(
|
const handleSendMessage = useCallback(
|
||||||
async (content: string) => {
|
async (content: string) => {
|
||||||
|
if (isCommand(content)) {
|
||||||
|
// Add user message immediately
|
||||||
|
const userMessage: Message = {
|
||||||
|
id: `user-${Date.now().toString()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
role: "user",
|
||||||
|
content: content.trim(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, userMessage]);
|
||||||
|
|
||||||
|
// Execute orchestrator command
|
||||||
|
const result = await executeCommand(content);
|
||||||
|
if (result) {
|
||||||
|
setMessages((prev) => [...prev, result]);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await sendMessage(content);
|
await sendMessage(content);
|
||||||
},
|
},
|
||||||
[sendMessage]
|
[isCommand, executeCommand, setMessages, sendMessage]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleSuggestionClick = useCallback((prompt: string): void => {
|
||||||
|
setSuggestionValue(prompt);
|
||||||
|
// Clear after a tick so input receives it, then focus
|
||||||
|
setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (authLoading) {
|
if (authLoading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -214,13 +278,17 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
{/* Messages Area */}
|
{/* Messages Area */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
<div className="mx-auto max-w-4xl px-4 py-6 lg:px-8">
|
||||||
<MessageList
|
{isEmptyConversation ? (
|
||||||
messages={messages as (Message & { thinking?: string })[]}
|
<ChatEmptyState onSuggestionClick={handleSuggestionClick} />
|
||||||
isLoading={isChatLoading}
|
) : (
|
||||||
isStreaming={isStreaming}
|
<MessageList
|
||||||
{...(streamingMessageId != null ? { streamingMessageId } : {})}
|
messages={messages as (Message & { thinking?: string })[]}
|
||||||
loadingQuip={loadingQuip}
|
isLoading={isChatLoading}
|
||||||
/>
|
isStreaming={isStreaming}
|
||||||
|
{...(streamingMessageId != null ? { streamingMessageId } : {})}
|
||||||
|
loadingQuip={loadingQuip}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,6 +356,10 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
|||||||
inputRef={inputRef}
|
inputRef={inputRef}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
onStopStreaming={abortStream}
|
onStopStreaming={abortStream}
|
||||||
|
onModelChange={setSelectedModel}
|
||||||
|
onTemperatureChange={setTemperature}
|
||||||
|
onMaxTokensChange={setMaxTokens}
|
||||||
|
{...(suggestionValue !== undefined ? { externalValue: suggestionValue } : {})}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
103
apps/web/src/components/chat/ChatEmptyState.test.tsx
Normal file
103
apps/web/src/components/chat/ChatEmptyState.test.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatEmptyState.test.tsx
|
||||||
|
* @description Tests for ChatEmptyState component: greeting, suggestions, click handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { ChatEmptyState } from "./ChatEmptyState";
|
||||||
|
|
||||||
|
describe("ChatEmptyState", () => {
|
||||||
|
it("should render the greeting heading", () => {
|
||||||
|
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
||||||
|
expect(screen.getByRole("heading", { name: /how can i help/i })).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render the empty state container", () => {
|
||||||
|
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
||||||
|
expect(screen.getByTestId("chat-empty-state")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render four suggestion buttons", () => {
|
||||||
|
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
||||||
|
// Four suggestions
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
expect(buttons.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render 'Explain this project' suggestion", () => {
|
||||||
|
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
||||||
|
expect(screen.getByText("Explain this project")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render 'Help me debug' suggestion", () => {
|
||||||
|
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
||||||
|
expect(screen.getByText("Help me debug")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render 'Write a test for' suggestion", () => {
|
||||||
|
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
||||||
|
expect(screen.getByText("Write a test for")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should render 'Refactor this code' suggestion", () => {
|
||||||
|
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
||||||
|
expect(screen.getByText("Refactor this code")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onSuggestionClick with the correct prompt when a suggestion is clicked", () => {
|
||||||
|
const onSuggestionClick = vi.fn();
|
||||||
|
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
|
||||||
|
|
||||||
|
const explainButton = screen.getByTestId("suggestion-explain-this-project");
|
||||||
|
fireEvent.click(explainButton);
|
||||||
|
|
||||||
|
expect(onSuggestionClick).toHaveBeenCalledOnce();
|
||||||
|
const [calledWith] = onSuggestionClick.mock.calls[0] as [string];
|
||||||
|
expect(calledWith).toContain("overview of this project");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onSuggestionClick for 'Help me debug' prompt", () => {
|
||||||
|
const onSuggestionClick = vi.fn();
|
||||||
|
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
|
||||||
|
|
||||||
|
const debugButton = screen.getByTestId("suggestion-help-me-debug");
|
||||||
|
fireEvent.click(debugButton);
|
||||||
|
|
||||||
|
expect(onSuggestionClick).toHaveBeenCalledOnce();
|
||||||
|
const [calledWith] = onSuggestionClick.mock.calls[0] as [string];
|
||||||
|
expect(calledWith).toContain("debugging");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onSuggestionClick for 'Write a test for' prompt", () => {
|
||||||
|
const onSuggestionClick = vi.fn();
|
||||||
|
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
|
||||||
|
|
||||||
|
const testButton = screen.getByTestId("suggestion-write-a-test-for");
|
||||||
|
fireEvent.click(testButton);
|
||||||
|
|
||||||
|
expect(onSuggestionClick).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onSuggestionClick for 'Refactor this code' prompt", () => {
|
||||||
|
const onSuggestionClick = vi.fn();
|
||||||
|
render(<ChatEmptyState onSuggestionClick={onSuggestionClick} />);
|
||||||
|
|
||||||
|
const refactorButton = screen.getByTestId("suggestion-refactor-this-code");
|
||||||
|
fireEvent.click(refactorButton);
|
||||||
|
|
||||||
|
expect(onSuggestionClick).toHaveBeenCalledOnce();
|
||||||
|
const [calledWith] = onSuggestionClick.mock.calls[0] as [string];
|
||||||
|
expect(calledWith).toContain("refactor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have accessible aria-label on each suggestion button", () => {
|
||||||
|
render(<ChatEmptyState onSuggestionClick={vi.fn()} />);
|
||||||
|
const buttons = screen.getAllByRole("button");
|
||||||
|
for (const button of buttons) {
|
||||||
|
const label = button.getAttribute("aria-label");
|
||||||
|
expect(label).toBeTruthy();
|
||||||
|
expect(label).toMatch(/suggestion:/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
99
apps/web/src/components/chat/ChatEmptyState.tsx
Normal file
99
apps/web/src/components/chat/ChatEmptyState.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
interface Suggestion {
|
||||||
|
label: string;
|
||||||
|
prompt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUGGESTIONS: Suggestion[] = [
|
||||||
|
{
|
||||||
|
label: "Explain this project",
|
||||||
|
prompt: "Can you give me an overview of this project and its key components?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Help me debug",
|
||||||
|
prompt: "I have a bug I need help debugging. Can you walk me through the process?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Write a test for",
|
||||||
|
prompt: "Can you help me write a test for the following function or component?",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Refactor this code",
|
||||||
|
prompt: "I have some code I'd like to refactor for better readability and maintainability.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ChatEmptyStateProps {
|
||||||
|
onSuggestionClick: (prompt: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatEmptyState({ onSuggestionClick }: ChatEmptyStateProps): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col items-center justify-center gap-6 py-12 px-4 text-center"
|
||||||
|
data-testid="chat-empty-state"
|
||||||
|
>
|
||||||
|
{/* Icon */}
|
||||||
|
<div
|
||||||
|
className="flex h-16 w-16 items-center justify-center rounded-2xl"
|
||||||
|
style={{ backgroundColor: "rgb(var(--accent-primary) / 0.12)" }}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8"
|
||||||
|
style={{ color: "rgb(var(--accent-primary))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Greeting */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-lg font-semibold" style={{ color: "rgb(var(--text-primary))" }}>
|
||||||
|
How can I help you today?
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm max-w-sm" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||||
|
Ask me anything — I can help with code, explanations, debugging, and more.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions */}
|
||||||
|
<div className="grid grid-cols-1 gap-2 w-full max-w-sm sm:grid-cols-2">
|
||||||
|
{SUGGESTIONS.map((suggestion) => (
|
||||||
|
<button
|
||||||
|
key={suggestion.label}
|
||||||
|
onClick={() => {
|
||||||
|
onSuggestionClick(suggestion.prompt);
|
||||||
|
}}
|
||||||
|
className="rounded-lg border px-3 py-2.5 text-left text-sm transition-all duration-150 hover:shadow-sm focus:outline-none focus:ring-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-1))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
color: "rgb(var(--text-secondary))",
|
||||||
|
}}
|
||||||
|
aria-label={`Suggestion: ${suggestion.label}`}
|
||||||
|
data-testid={`suggestion-${suggestion.label.toLowerCase().replace(/\s+/g, "-")}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="block text-xs font-medium mb-0.5"
|
||||||
|
style={{ color: "rgb(var(--text-primary))" }}
|
||||||
|
>
|
||||||
|
{suggestion.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="block text-xs leading-relaxed line-clamp-2"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
>
|
||||||
|
{suggestion.prompt}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
486
apps/web/src/components/chat/ChatInput.test.tsx
Normal file
486
apps/web/src/components/chat/ChatInput.test.tsx
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
/**
|
||||||
|
* @file ChatInput.test.tsx
|
||||||
|
* @description Tests for ChatInput: model selector, temperature/params, localStorage persistence,
|
||||||
|
* and command autocomplete.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, fireEvent, waitFor, within, act } from "@testing-library/react";
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import {
|
||||||
|
ChatInput,
|
||||||
|
AVAILABLE_MODELS,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
DEFAULT_TEMPERATURE,
|
||||||
|
DEFAULT_MAX_TOKENS,
|
||||||
|
} from "./ChatInput";
|
||||||
|
|
||||||
|
// Mock fetch for version.json
|
||||||
|
beforeEach(() => {
|
||||||
|
global.fetch = vi.fn().mockRejectedValue(new Error("Not found"));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Get the first non-default model from the list */
|
||||||
|
function getNonDefaultModel(): (typeof AVAILABLE_MODELS)[number] {
|
||||||
|
const model = AVAILABLE_MODELS.find((m) => m.id !== DEFAULT_MODEL);
|
||||||
|
if (!model) throw new Error("No non-default model found");
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ChatInput — model selector", () => {
|
||||||
|
it("should render the model selector chip showing the default model", () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const defaultLabel =
|
||||||
|
AVAILABLE_MODELS.find((m) => m.id === DEFAULT_MODEL)?.label ?? DEFAULT_MODEL;
|
||||||
|
expect(screen.getByText(defaultLabel)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open the model dropdown when the chip is clicked", () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const chip = screen.getByLabelText(/model:/i);
|
||||||
|
fireEvent.click(chip);
|
||||||
|
|
||||||
|
// The dropdown (listbox role) should be visible
|
||||||
|
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
||||||
|
expect(listbox).toBeDefined();
|
||||||
|
|
||||||
|
// All model options should appear in the dropdown
|
||||||
|
const options = within(listbox).getAllByRole("option");
|
||||||
|
expect(options.length).toBe(AVAILABLE_MODELS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onModelChange when a model is selected", async () => {
|
||||||
|
const onModelChange = vi.fn();
|
||||||
|
render(<ChatInput onSend={vi.fn()} onModelChange={onModelChange} />);
|
||||||
|
|
||||||
|
const chip = screen.getByLabelText(/model:/i);
|
||||||
|
fireEvent.click(chip);
|
||||||
|
|
||||||
|
const targetModel = getNonDefaultModel();
|
||||||
|
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
||||||
|
const targetOption = within(listbox).getByText(targetModel.label);
|
||||||
|
fireEvent.click(targetOption);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = onModelChange.mock.calls.map((c: unknown[]) => c[0]);
|
||||||
|
expect(calls).toContain(targetModel.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist the selected model in localStorage", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
|
||||||
|
const chip = screen.getByLabelText(/model:/i);
|
||||||
|
fireEvent.click(chip);
|
||||||
|
|
||||||
|
const targetModel = getNonDefaultModel();
|
||||||
|
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
||||||
|
const targetOption = within(listbox).getByText(targetModel.label);
|
||||||
|
fireEvent.click(targetOption);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(localStorage.getItem("chat:selectedModel")).toBe(targetModel.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should restore the model from localStorage on mount", async () => {
|
||||||
|
const targetModel = getNonDefaultModel();
|
||||||
|
localStorage.setItem("chat:selectedModel", targetModel.id);
|
||||||
|
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(targetModel.label)).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should close the dropdown after selecting a model", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
|
||||||
|
const chip = screen.getByLabelText(/model:/i);
|
||||||
|
fireEvent.click(chip);
|
||||||
|
|
||||||
|
const targetModel = getNonDefaultModel();
|
||||||
|
const listbox = screen.getByRole("listbox", { name: /available models/i });
|
||||||
|
const targetOption = within(listbox).getByText(targetModel.label);
|
||||||
|
fireEvent.click(targetOption);
|
||||||
|
|
||||||
|
// After selection, dropdown should close
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByRole("listbox")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have aria-expanded on the model chip button", () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const chip = screen.getByLabelText(/model:/i);
|
||||||
|
expect(chip.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
|
||||||
|
fireEvent.click(chip);
|
||||||
|
expect(chip.getAttribute("aria-expanded")).toBe("true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatInput — temperature and max tokens", () => {
|
||||||
|
it("should render the settings/params button", () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const settingsBtn = screen.getByLabelText(/chat parameters/i);
|
||||||
|
expect(settingsBtn).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open the params popover when settings button is clicked", () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const settingsBtn = screen.getByLabelText(/chat parameters/i);
|
||||||
|
fireEvent.click(settingsBtn);
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/temperature/i)).toBeDefined();
|
||||||
|
expect(screen.getByLabelText(/maximum tokens/i)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the default temperature value", () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||||
|
|
||||||
|
const slider = screen.getByLabelText(/temperature/i);
|
||||||
|
expect(parseFloat((slider as HTMLInputElement).value)).toBeCloseTo(DEFAULT_TEMPERATURE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onTemperatureChange when the slider is moved", async () => {
|
||||||
|
const onTemperatureChange = vi.fn();
|
||||||
|
render(<ChatInput onSend={vi.fn()} onTemperatureChange={onTemperatureChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||||
|
|
||||||
|
const slider = screen.getByLabelText(/temperature/i);
|
||||||
|
fireEvent.change(slider, { target: { value: "1.2" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]);
|
||||||
|
expect(calls).toContain(1.2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist temperature in localStorage", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||||
|
|
||||||
|
const slider = screen.getByLabelText(/temperature/i);
|
||||||
|
fireEvent.change(slider, { target: { value: "0.5" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(localStorage.getItem("chat:temperature")).toBe("0.5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should restore temperature from localStorage on mount", async () => {
|
||||||
|
localStorage.setItem("chat:temperature", "1.5");
|
||||||
|
|
||||||
|
const onTemperatureChange = vi.fn();
|
||||||
|
render(<ChatInput onSend={vi.fn()} onTemperatureChange={onTemperatureChange} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = onTemperatureChange.mock.calls.map((c: unknown[]) => c[0]);
|
||||||
|
expect(calls).toContain(1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the default max tokens value", () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/maximum tokens/i);
|
||||||
|
expect(parseInt((input as HTMLInputElement).value, 10)).toBe(DEFAULT_MAX_TOKENS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onMaxTokensChange when the max tokens input changes", async () => {
|
||||||
|
const onMaxTokensChange = vi.fn();
|
||||||
|
render(<ChatInput onSend={vi.fn()} onMaxTokensChange={onMaxTokensChange} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/maximum tokens/i);
|
||||||
|
fireEvent.change(input, { target: { value: "8192" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]);
|
||||||
|
expect(calls).toContain(8192);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should persist max tokens in localStorage", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/chat parameters/i));
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/maximum tokens/i);
|
||||||
|
fireEvent.change(input, { target: { value: "2000" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(localStorage.getItem("chat:maxTokens")).toBe("2000");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should restore max tokens from localStorage on mount", async () => {
|
||||||
|
localStorage.setItem("chat:maxTokens", "8000");
|
||||||
|
|
||||||
|
const onMaxTokensChange = vi.fn();
|
||||||
|
render(<ChatInput onSend={vi.fn()} onMaxTokensChange={onMaxTokensChange} />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const calls = onMaxTokensChange.mock.calls.map((c: unknown[]) => c[0]);
|
||||||
|
expect(calls).toContain(8000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatInput — externalValue (suggestion fill)", () => {
|
||||||
|
it("should update the textarea when externalValue is provided", async () => {
|
||||||
|
const { rerender } = render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||||
|
|
||||||
|
rerender(<ChatInput onSend={vi.fn()} externalValue="Hello suggestion" />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("Hello suggestion");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatInput — send behavior", () => {
|
||||||
|
it("should call onSend with the message when the send button is clicked", async () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
render(<ChatInput onSend={onSend} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
fireEvent.change(textarea, { target: { value: "Hello world" } });
|
||||||
|
|
||||||
|
const sendButton = screen.getByLabelText(/send message/i);
|
||||||
|
fireEvent.click(sendButton);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSend).toHaveBeenCalledWith("Hello world");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should clear the textarea after sending", async () => {
|
||||||
|
const onSend = vi.fn();
|
||||||
|
render(<ChatInput onSend={onSend} />);
|
||||||
|
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
fireEvent.change(textarea, { target: { value: "Hello world" } });
|
||||||
|
fireEvent.click(screen.getByLabelText(/send message/i));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should show the stop button when streaming", () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} isStreaming={true} />);
|
||||||
|
expect(screen.getByLabelText(/stop generating/i)).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should call onStopStreaming when stop button is clicked", () => {
|
||||||
|
const onStop = vi.fn();
|
||||||
|
render(<ChatInput onSend={vi.fn()} isStreaming={true} onStopStreaming={onStop} />);
|
||||||
|
fireEvent.click(screen.getByLabelText(/stop generating/i));
|
||||||
|
expect(onStop).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ChatInput — command autocomplete", () => {
|
||||||
|
it("shows no autocomplete for regular text", () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "hello world" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows autocomplete dropdown when user types /", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows all commands when only / is typed", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = screen.getByTestId("command-autocomplete");
|
||||||
|
expect(dropdown).toHaveTextContent("/status");
|
||||||
|
expect(dropdown).toHaveTextContent("/agents");
|
||||||
|
expect(dropdown).toHaveTextContent("/jobs");
|
||||||
|
expect(dropdown).toHaveTextContent("/pause");
|
||||||
|
expect(dropdown).toHaveTextContent("/resume");
|
||||||
|
expect(dropdown).toHaveTextContent("/help");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters commands by typed prefix", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/ag" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = screen.getByTestId("command-autocomplete");
|
||||||
|
expect(dropdown).toHaveTextContent("/agents");
|
||||||
|
expect(dropdown).not.toHaveTextContent("/status");
|
||||||
|
expect(dropdown).not.toHaveTextContent("/pause");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismisses autocomplete on Escape key", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts first command on Tab key", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/stat" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Tab" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("/status ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates with ArrowDown key", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "ArrowDown" });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const options = screen.getAllByRole("option");
|
||||||
|
// Second item should be selected after ArrowDown
|
||||||
|
expect(options[1]).toHaveAttribute("aria-selected", "true");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fills command when clicking a suggestion", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click on /agents option
|
||||||
|
const options = screen.getAllByRole("option");
|
||||||
|
const agentsOption = options.find((o) => o.textContent.includes("/agents"));
|
||||||
|
if (!agentsOption) throw new Error("Could not find /agents option");
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(agentsOption);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toBe("/agents ");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows command descriptions", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const dropdown = screen.getByTestId("command-autocomplete");
|
||||||
|
expect(dropdown).toHaveTextContent("Show orchestrator health");
|
||||||
|
expect(dropdown).toHaveTextContent("Pause the job queue");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides autocomplete when input no longer starts with /", async () => {
|
||||||
|
render(<ChatInput onSend={vi.fn()} />);
|
||||||
|
const textarea = screen.getByLabelText(/message input/i);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "/" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("command-autocomplete")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByTestId("command-autocomplete")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,67 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { KeyboardEvent, RefObject } from "react";
|
import type { KeyboardEvent, RefObject } from "react";
|
||||||
import { useCallback, useState, useEffect } from "react";
|
import { useCallback, useState, useEffect, useRef } from "react";
|
||||||
|
import { ORCHESTRATOR_COMMANDS } from "@/hooks/useOrchestratorCommands";
|
||||||
|
|
||||||
|
export const AVAILABLE_MODELS = [
|
||||||
|
{ id: "llama3.2", label: "Llama 3.2" },
|
||||||
|
{ id: "claude-3.5-sonnet", label: "Claude 3.5 Sonnet" },
|
||||||
|
{ id: "gpt-4o", label: "GPT-4o" },
|
||||||
|
{ id: "deepseek-r1", label: "DeepSeek R1" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ModelId = (typeof AVAILABLE_MODELS)[number]["id"];
|
||||||
|
|
||||||
|
const STORAGE_KEY_MODEL = "chat:selectedModel";
|
||||||
|
const STORAGE_KEY_TEMPERATURE = "chat:temperature";
|
||||||
|
const STORAGE_KEY_MAX_TOKENS = "chat:maxTokens";
|
||||||
|
|
||||||
|
export const DEFAULT_TEMPERATURE = 0.7;
|
||||||
|
export const DEFAULT_MAX_TOKENS = 4096;
|
||||||
|
export const DEFAULT_MODEL: ModelId = "llama3.2";
|
||||||
|
|
||||||
|
function loadStoredModel(): ModelId {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_MODEL);
|
||||||
|
if (stored && AVAILABLE_MODELS.some((m) => m.id === stored)) {
|
||||||
|
return stored as ModelId;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
return DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStoredTemperature(): number {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_TEMPERATURE);
|
||||||
|
if (stored !== null) {
|
||||||
|
const parsed = parseFloat(stored);
|
||||||
|
if (!isNaN(parsed) && parsed >= 0 && parsed <= 2) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
return DEFAULT_TEMPERATURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStoredMaxTokens(): number {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY_MAX_TOKENS);
|
||||||
|
if (stored !== null) {
|
||||||
|
const parsed = parseInt(stored, 10);
|
||||||
|
if (!isNaN(parsed) && parsed >= 100 && parsed <= 32000) {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// localStorage not available
|
||||||
|
}
|
||||||
|
return DEFAULT_MAX_TOKENS;
|
||||||
|
}
|
||||||
|
|
||||||
interface ChatInputProps {
|
interface ChatInputProps {
|
||||||
onSend: (message: string) => void;
|
onSend: (message: string) => void;
|
||||||
@@ -9,6 +69,11 @@ interface ChatInputProps {
|
|||||||
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
inputRef?: RefObject<HTMLTextAreaElement | null>;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
onStopStreaming?: () => void;
|
onStopStreaming?: () => void;
|
||||||
|
onModelChange?: (model: ModelId) => void;
|
||||||
|
onTemperatureChange?: (temperature: number) => void;
|
||||||
|
onMaxTokensChange?: (maxTokens: number) => void;
|
||||||
|
onSuggestionFill?: (text: string) => void;
|
||||||
|
externalValue?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatInput({
|
export function ChatInput({
|
||||||
@@ -17,9 +82,57 @@ export function ChatInput({
|
|||||||
inputRef,
|
inputRef,
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
onStopStreaming,
|
onStopStreaming,
|
||||||
|
onModelChange,
|
||||||
|
onTemperatureChange,
|
||||||
|
onMaxTokensChange,
|
||||||
|
externalValue,
|
||||||
}: ChatInputProps): React.JSX.Element {
|
}: ChatInputProps): React.JSX.Element {
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [version, setVersion] = useState<string | null>(null);
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
const [selectedModel, setSelectedModel] = useState<ModelId>(DEFAULT_MODEL);
|
||||||
|
const [temperature, setTemperature] = useState<number>(DEFAULT_TEMPERATURE);
|
||||||
|
const [maxTokens, setMaxTokens] = useState<number>(DEFAULT_MAX_TOKENS);
|
||||||
|
const [isModelDropdownOpen, setIsModelDropdownOpen] = useState(false);
|
||||||
|
const [isParamsOpen, setIsParamsOpen] = useState(false);
|
||||||
|
|
||||||
|
// Command autocomplete state
|
||||||
|
const [commandSuggestions, setCommandSuggestions] = useState<typeof ORCHESTRATOR_COMMANDS>([]);
|
||||||
|
const [highlightedCommandIndex, setHighlightedCommandIndex] = useState(0);
|
||||||
|
const commandDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const modelDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const paramsDropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Stable refs for callbacks so the mount effect stays dependency-free
|
||||||
|
const onModelChangeRef = useRef(onModelChange);
|
||||||
|
onModelChangeRef.current = onModelChange;
|
||||||
|
const onTemperatureChangeRef = useRef(onTemperatureChange);
|
||||||
|
onTemperatureChangeRef.current = onTemperatureChange;
|
||||||
|
const onMaxTokensChangeRef = useRef(onMaxTokensChange);
|
||||||
|
onMaxTokensChangeRef.current = onMaxTokensChange;
|
||||||
|
|
||||||
|
// Load persisted values from localStorage on mount only
|
||||||
|
useEffect(() => {
|
||||||
|
const storedModel = loadStoredModel();
|
||||||
|
const storedTemperature = loadStoredTemperature();
|
||||||
|
const storedMaxTokens = loadStoredMaxTokens();
|
||||||
|
|
||||||
|
setSelectedModel(storedModel);
|
||||||
|
setTemperature(storedTemperature);
|
||||||
|
setMaxTokens(storedMaxTokens);
|
||||||
|
|
||||||
|
// Notify parent of initial values via refs to avoid stale closure
|
||||||
|
onModelChangeRef.current?.(storedModel);
|
||||||
|
onTemperatureChangeRef.current?.(storedTemperature);
|
||||||
|
onMaxTokensChangeRef.current?.(storedMaxTokens);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync external value (e.g. from suggestion clicks)
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalValue !== undefined) {
|
||||||
|
setMessage(externalValue);
|
||||||
|
}
|
||||||
|
}, [externalValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
interface VersionData {
|
interface VersionData {
|
||||||
@@ -40,6 +153,54 @@ export function ChatInput({
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Update command autocomplete suggestions when message changes
|
||||||
|
useEffect(() => {
|
||||||
|
const trimmed = message.trimStart();
|
||||||
|
if (!trimmed.startsWith("/")) {
|
||||||
|
setCommandSuggestions([]);
|
||||||
|
setHighlightedCommandIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the input contains a space, a command has been completed — no suggestions
|
||||||
|
if (trimmed.includes(" ")) {
|
||||||
|
setCommandSuggestions([]);
|
||||||
|
setHighlightedCommandIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typedCommand = trimmed.toLowerCase();
|
||||||
|
|
||||||
|
// Build flat list including aliases
|
||||||
|
const matches = ORCHESTRATOR_COMMANDS.filter((cmd) => {
|
||||||
|
if (cmd.name.startsWith(typedCommand)) return true;
|
||||||
|
if (cmd.aliases?.some((a) => a.startsWith(typedCommand))) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
setCommandSuggestions(matches);
|
||||||
|
setHighlightedCommandIndex(0);
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
// Close dropdowns on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e: MouseEvent): void => {
|
||||||
|
if (modelDropdownRef.current && !modelDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setIsModelDropdownOpen(false);
|
||||||
|
}
|
||||||
|
if (paramsDropdownRef.current && !paramsDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setIsParamsOpen(false);
|
||||||
|
}
|
||||||
|
if (commandDropdownRef.current && !commandDropdownRef.current.contains(e.target as Node)) {
|
||||||
|
setCommandSuggestions([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
|
return (): void => {
|
||||||
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleSubmit = useCallback(() => {
|
const handleSubmit = useCallback(() => {
|
||||||
if (message.trim() && !disabled && !isStreaming) {
|
if (message.trim() && !disabled && !isStreaming) {
|
||||||
onSend(message);
|
onSend(message);
|
||||||
@@ -51,8 +212,48 @@ export function ChatInput({
|
|||||||
onStopStreaming?.();
|
onStopStreaming?.();
|
||||||
}, [onStopStreaming]);
|
}, [onStopStreaming]);
|
||||||
|
|
||||||
|
const acceptCommand = useCallback((cmdName: string): void => {
|
||||||
|
setMessage(cmdName + " ");
|
||||||
|
setCommandSuggestions([]);
|
||||||
|
setHighlightedCommandIndex(0);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleKeyDown = useCallback(
|
const handleKeyDown = useCallback(
|
||||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
// Command autocomplete navigation
|
||||||
|
if (commandSuggestions.length > 0) {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedCommandIndex((prev) =>
|
||||||
|
prev < commandSuggestions.length - 1 ? prev + 1 : 0
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
setHighlightedCommandIndex((prev) =>
|
||||||
|
prev > 0 ? prev - 1 : commandSuggestions.length - 1
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
e.key === "Tab" ||
|
||||||
|
(e.key === "Enter" && !e.shiftKey && commandSuggestions.length > 0)
|
||||||
|
) {
|
||||||
|
e.preventDefault();
|
||||||
|
const selected = commandSuggestions[highlightedCommandIndex];
|
||||||
|
if (selected) {
|
||||||
|
acceptCommand(selected.name);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
setCommandSuggestions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === "Enter" && !e.shiftKey) {
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
handleSubmit();
|
handleSubmit();
|
||||||
@@ -62,9 +263,52 @@ export function ChatInput({
|
|||||||
handleSubmit();
|
handleSubmit();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[handleSubmit]
|
[handleSubmit, commandSuggestions, highlightedCommandIndex, acceptCommand]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleModelSelect = useCallback(
|
||||||
|
(model: ModelId): void => {
|
||||||
|
setSelectedModel(model);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_MODEL, model);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
onModelChange?.(model);
|
||||||
|
setIsModelDropdownOpen(false);
|
||||||
|
},
|
||||||
|
[onModelChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTemperatureChange = useCallback(
|
||||||
|
(value: number): void => {
|
||||||
|
setTemperature(value);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_TEMPERATURE, value.toString());
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
onTemperatureChange?.(value);
|
||||||
|
},
|
||||||
|
[onTemperatureChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMaxTokensChange = useCallback(
|
||||||
|
(value: number): void => {
|
||||||
|
setMaxTokens(value);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_MAX_TOKENS, value.toString());
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
onMaxTokensChange?.(value);
|
||||||
|
},
|
||||||
|
[onMaxTokensChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedModelLabel =
|
||||||
|
AVAILABLE_MODELS.find((m) => m.id === selectedModel)?.label ?? selectedModel;
|
||||||
|
|
||||||
const characterCount = message.length;
|
const characterCount = message.length;
|
||||||
const maxCharacters = 4000;
|
const maxCharacters = 4000;
|
||||||
const isNearLimit = characterCount > maxCharacters * 0.9;
|
const isNearLimit = characterCount > maxCharacters * 0.9;
|
||||||
@@ -72,7 +316,279 @@ export function ChatInput({
|
|||||||
const isInputDisabled = disabled ?? false;
|
const isInputDisabled = disabled ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
|
{/* Model Selector + Params Row */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Model Selector */}
|
||||||
|
<div className="relative" ref={modelDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsModelDropdownOpen((prev) => !prev);
|
||||||
|
setIsParamsOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
||||||
|
style={{
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
backgroundColor: "rgb(var(--surface-1))",
|
||||||
|
color: "rgb(var(--text-secondary))",
|
||||||
|
}}
|
||||||
|
aria-label={`Model: ${selectedModelLabel}. Click to change`}
|
||||||
|
aria-expanded={isModelDropdownOpen}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
title="Select AI model"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3 w-3 flex-shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83" />
|
||||||
|
</svg>
|
||||||
|
<span>{selectedModelLabel}</span>
|
||||||
|
<svg
|
||||||
|
className={`h-3 w-3 transition-transform ${isModelDropdownOpen ? "rotate-180" : ""}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6 9l6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Model Dropdown */}
|
||||||
|
{isModelDropdownOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full left-0 mb-1 z-50 min-w-[160px] rounded-lg border shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Available models"
|
||||||
|
>
|
||||||
|
{AVAILABLE_MODELS.map((model) => (
|
||||||
|
<button
|
||||||
|
key={model.id}
|
||||||
|
role="option"
|
||||||
|
aria-selected={model.id === selectedModel}
|
||||||
|
onClick={() => {
|
||||||
|
handleModelSelect(model.id);
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 text-left text-xs transition-colors first:rounded-t-lg last:rounded-b-lg hover:bg-black/5"
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
model.id === selectedModel
|
||||||
|
? "rgb(var(--accent-primary))"
|
||||||
|
: "rgb(var(--text-primary))",
|
||||||
|
fontWeight: model.id === selectedModel ? 600 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{model.label}
|
||||||
|
{model.id === selectedModel && (
|
||||||
|
<svg
|
||||||
|
className="inline-block ml-1.5 h-3 w-3"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Settings / Params Icon */}
|
||||||
|
<div className="relative" ref={paramsDropdownRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsParamsOpen((prev) => !prev);
|
||||||
|
setIsModelDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center rounded-full border p-1 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
||||||
|
style={{
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
backgroundColor: isParamsOpen ? "rgb(var(--surface-2))" : "rgb(var(--surface-1))",
|
||||||
|
color: "rgb(var(--text-muted))",
|
||||||
|
}}
|
||||||
|
aria-label="Chat parameters"
|
||||||
|
aria-expanded={isParamsOpen}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
title="Configure temperature and max tokens"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Params Popover */}
|
||||||
|
{isParamsOpen && (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-full left-0 mb-1 z-50 w-64 rounded-lg border p-4 shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Chat parameters"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
className="mb-3 text-xs font-semibold uppercase tracking-wide"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
>
|
||||||
|
Parameters
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Temperature */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="mb-1.5 flex items-center justify-between">
|
||||||
|
<label
|
||||||
|
className="text-xs font-medium"
|
||||||
|
style={{ color: "rgb(var(--text-secondary))" }}
|
||||||
|
htmlFor="temperature-slider"
|
||||||
|
>
|
||||||
|
Temperature
|
||||||
|
</label>
|
||||||
|
<span
|
||||||
|
className="text-xs font-mono tabular-nums"
|
||||||
|
style={{ color: "rgb(var(--accent-primary))" }}
|
||||||
|
>
|
||||||
|
{temperature.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="temperature-slider"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.1}
|
||||||
|
value={temperature}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleTemperatureChange(parseFloat(e.target.value));
|
||||||
|
}}
|
||||||
|
className="w-full h-1.5 rounded-full appearance-none cursor-pointer"
|
||||||
|
style={{
|
||||||
|
accentColor: "rgb(var(--accent-primary))",
|
||||||
|
backgroundColor: "rgb(var(--surface-2))",
|
||||||
|
}}
|
||||||
|
aria-label={`Temperature: ${temperature.toFixed(1)}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="mt-1 flex justify-between text-[10px]"
|
||||||
|
style={{ color: "rgb(var(--text-muted))" }}
|
||||||
|
>
|
||||||
|
<span>Precise</span>
|
||||||
|
<span>Creative</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Max Tokens */}
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
className="mb-1.5 block text-xs font-medium"
|
||||||
|
style={{ color: "rgb(var(--text-secondary))" }}
|
||||||
|
htmlFor="max-tokens-input"
|
||||||
|
>
|
||||||
|
Max Tokens
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="max-tokens-input"
|
||||||
|
type="number"
|
||||||
|
min={100}
|
||||||
|
max={32000}
|
||||||
|
step={100}
|
||||||
|
value={maxTokens}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = parseInt(e.target.value, 10);
|
||||||
|
if (!isNaN(val) && val >= 100 && val <= 32000) {
|
||||||
|
handleMaxTokensChange(val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-md border px-2.5 py-1.5 text-xs outline-none focus:ring-2"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-1))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
color: "rgb(var(--text-primary))",
|
||||||
|
}}
|
||||||
|
aria-label="Maximum tokens"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px]" style={{ color: "rgb(var(--text-muted))" }}>
|
||||||
|
100 – 32,000
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Command Autocomplete Dropdown */}
|
||||||
|
{commandSuggestions.length > 0 && (
|
||||||
|
<div
|
||||||
|
ref={commandDropdownRef}
|
||||||
|
className="rounded-lg border shadow-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgb(var(--surface-0))",
|
||||||
|
borderColor: "rgb(var(--border-default))",
|
||||||
|
}}
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Command suggestions"
|
||||||
|
data-testid="command-autocomplete"
|
||||||
|
>
|
||||||
|
{commandSuggestions.map((cmd, idx) => (
|
||||||
|
<button
|
||||||
|
key={cmd.name}
|
||||||
|
role="option"
|
||||||
|
aria-selected={idx === highlightedCommandIndex}
|
||||||
|
onClick={() => {
|
||||||
|
acceptCommand(cmd.name);
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors first:rounded-t-lg last:rounded-b-lg"
|
||||||
|
style={{
|
||||||
|
backgroundColor:
|
||||||
|
idx === highlightedCommandIndex
|
||||||
|
? "rgb(var(--accent-primary) / 0.1)"
|
||||||
|
: "transparent",
|
||||||
|
color: "rgb(var(--text-primary))",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-xs font-semibold"
|
||||||
|
style={{ color: "rgb(var(--accent-primary))" }}
|
||||||
|
>
|
||||||
|
{cmd.name}
|
||||||
|
</span>
|
||||||
|
{cmd.aliases && cmd.aliases.length > 0 && (
|
||||||
|
<span className="text-xs" style={{ color: "rgb(var(--text-muted))" }}>
|
||||||
|
({cmd.aliases.join(", ")})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs ml-auto" style={{ color: "rgb(var(--text-secondary))" }}>
|
||||||
|
{cmd.description}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Input Container */}
|
{/* Input Container */}
|
||||||
<div
|
<div
|
||||||
className="relative rounded-lg border transition-all duration-150"
|
className="relative rounded-lg border transition-all duration-150"
|
||||||
|
|||||||
@@ -250,6 +250,46 @@ describe("ChatOverlay", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("new conversation button", () => {
|
||||||
|
it("should render the new conversation button when chat is open", async () => {
|
||||||
|
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
|
||||||
|
vi.mocked(useChatOverlay).mockReturnValue({
|
||||||
|
isOpen: true,
|
||||||
|
isMinimized: false,
|
||||||
|
open: mockOpen,
|
||||||
|
close: mockClose,
|
||||||
|
minimize: mockMinimize,
|
||||||
|
expand: mockExpand,
|
||||||
|
toggle: mockToggle,
|
||||||
|
toggleMinimize: mockToggleMinimize,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const newConvBtn = screen.getByRole("button", { name: /new conversation/i });
|
||||||
|
expect(newConvBtn).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have a tooltip on the new conversation button", async () => {
|
||||||
|
const { useChatOverlay } = await import("../../hooks/useChatOverlay");
|
||||||
|
vi.mocked(useChatOverlay).mockReturnValue({
|
||||||
|
isOpen: true,
|
||||||
|
isMinimized: false,
|
||||||
|
open: mockOpen,
|
||||||
|
close: mockClose,
|
||||||
|
minimize: mockMinimize,
|
||||||
|
expand: mockExpand,
|
||||||
|
toggle: mockToggle,
|
||||||
|
toggleMinimize: mockToggleMinimize,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ChatOverlay />);
|
||||||
|
|
||||||
|
const newConvBtn = screen.getByRole("button", { name: /new conversation/i });
|
||||||
|
expect(newConvBtn.getAttribute("title")).toContain("Cmd+N");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("responsive design", () => {
|
describe("responsive design", () => {
|
||||||
it("should render as a sidebar on desktop", () => {
|
it("should render as a sidebar on desktop", () => {
|
||||||
render(<ChatOverlay />);
|
render(<ChatOverlay />);
|
||||||
|
|||||||
@@ -164,6 +164,27 @@ export function ChatOverlay(): React.JSX.Element {
|
|||||||
|
|
||||||
{/* Header Controls */}
|
{/* Header Controls */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
{/* New Conversation Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
chatRef.current?.startNewConversation(null);
|
||||||
|
}}
|
||||||
|
className="rounded p-1.5 transition-colors hover:bg-black/5 focus:outline-none focus:ring-2"
|
||||||
|
aria-label="New conversation"
|
||||||
|
title="New conversation (Cmd+N)"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-4 w-4"
|
||||||
|
style={{ color: "rgb(var(--text-secondary))" }}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* Minimize Button */}
|
{/* Minimize Button */}
|
||||||
<button
|
<button
|
||||||
onClick={minimize}
|
onClick={minimize}
|
||||||
|
|||||||
@@ -11,9 +11,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { Chat, type ChatRef, type NewConversationData } from "./Chat";
|
export { Chat, type ChatRef, type NewConversationData } from "./Chat";
|
||||||
export { ChatInput } from "./ChatInput";
|
export {
|
||||||
|
ChatInput,
|
||||||
|
AVAILABLE_MODELS,
|
||||||
|
DEFAULT_MODEL,
|
||||||
|
DEFAULT_TEMPERATURE,
|
||||||
|
DEFAULT_MAX_TOKENS,
|
||||||
|
} from "./ChatInput";
|
||||||
|
export type { ModelId } from "./ChatInput";
|
||||||
export { MessageList } from "./MessageList";
|
export { MessageList } from "./MessageList";
|
||||||
export { ConversationSidebar, type ConversationSidebarRef } from "./ConversationSidebar";
|
export { ConversationSidebar, type ConversationSidebarRef } from "./ConversationSidebar";
|
||||||
export { BackendStatusBanner } from "./BackendStatusBanner";
|
export { BackendStatusBanner } from "./BackendStatusBanner";
|
||||||
export { ChatOverlay } from "./ChatOverlay";
|
export { ChatOverlay } from "./ChatOverlay";
|
||||||
|
export { ChatEmptyState } from "./ChatEmptyState";
|
||||||
export type { Message } from "@/hooks/useChat";
|
export type { Message } from "@/hooks/useChat";
|
||||||
|
|||||||
@@ -254,7 +254,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
badge: { label: "live", pulse: true },
|
badge: { label: "live", pulse: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: "#terminal",
|
href: "/terminal",
|
||||||
label: "Terminal",
|
label: "Terminal",
|
||||||
icon: <IconTerminal />,
|
icon: <IconTerminal />,
|
||||||
},
|
},
|
||||||
@@ -464,7 +464,7 @@ function UserCard({ collapsed }: UserCardProps): React.JSX.Element {
|
|||||||
|
|
||||||
const displayName = user?.name ?? "User";
|
const displayName = user?.name ?? "User";
|
||||||
const initials = getInitials(displayName);
|
const initials = getInitials(displayName);
|
||||||
const role = user?.workspaceRole ?? "Member";
|
const role = "Member";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer
|
<footer
|
||||||
|
|||||||
368
apps/web/src/components/terminal/AgentTerminal.test.tsx
Normal file
368
apps/web/src/components/terminal/AgentTerminal.test.tsx
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
/**
|
||||||
|
* @file AgentTerminal.test.tsx
|
||||||
|
* @description Unit tests for the AgentTerminal component
|
||||||
|
*
|
||||||
|
* Tests cover:
|
||||||
|
* - Output rendering
|
||||||
|
* - Status display (status indicator + badge)
|
||||||
|
* - ANSI stripping
|
||||||
|
* - Agent header information (type, duration, jobId)
|
||||||
|
* - Auto-scroll behavior
|
||||||
|
* - Copy-to-clipboard
|
||||||
|
* - Error message display
|
||||||
|
* - Empty state rendering
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent, act } from "@testing-library/react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
import { AgentTerminal } from "./AgentTerminal";
|
||||||
|
import type { AgentSession } from "@/hooks/useAgentStream";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mock navigator.clipboard
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const mockWriteText = vi.fn(() => Promise.resolve());
|
||||||
|
Object.defineProperty(navigator, "clipboard", {
|
||||||
|
value: { writeText: mockWriteText },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Factory helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function makeAgent(overrides: Partial<AgentSession> = {}): AgentSession {
|
||||||
|
return {
|
||||||
|
agentId: "test-agent-1",
|
||||||
|
agentType: "worker",
|
||||||
|
status: "running",
|
||||||
|
outputLines: [],
|
||||||
|
startedAt: Date.now() - 5000, // 5s ago
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tests
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("AgentTerminal", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Rendering
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the agent terminal container", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("agent-terminal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has region role with agent type label", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent({ agentType: "planner" })} />) as ReactElement);
|
||||||
|
expect(screen.getByRole("region", { name: "Agent output: planner" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets data-agent-id attribute", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent({ agentId: "my-agent-123" })} />) as ReactElement);
|
||||||
|
const container = screen.getByTestId("agent-terminal");
|
||||||
|
expect(container).toHaveAttribute("data-agent-id", "my-agent-123");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies className prop to the outer container", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent()} className="custom-cls" />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("agent-terminal")).toHaveClass("custom-cls");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Header
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("header", () => {
|
||||||
|
it("renders the agent type label", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent({ agentType: "coordinator" })} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("agent-type-label")).toHaveTextContent("coordinator");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes jobId in the label when provided", () => {
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<AgentTerminal agent={makeAgent({ agentType: "worker", jobId: "job-42" })} />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("agent-type-label")).toHaveTextContent("worker · job-42");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show jobId separator when jobId is absent", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent({ agentType: "worker" })} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("agent-type-label")).not.toHaveTextContent("·");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the duration element", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("agent-duration")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows seconds for short-running agents", () => {
|
||||||
|
const agent = makeAgent({ startedAt: Date.now() - 8000 });
|
||||||
|
render((<AgentTerminal agent={agent} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("agent-duration")).toHaveTextContent("s");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows minutes for long-running agents", () => {
|
||||||
|
const agent = makeAgent({ startedAt: Date.now() - 125000 }); // 2m 5s
|
||||||
|
render((<AgentTerminal agent={agent} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("agent-duration")).toHaveTextContent("m");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Status indicator
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("status indicator", () => {
|
||||||
|
it("shows a running indicator for running status", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
|
||||||
|
const indicator = screen.getByTestId("status-indicator");
|
||||||
|
expect(indicator).toHaveAttribute("data-status", "running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a spawning indicator for spawning status", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent({ status: "spawning" })} />) as ReactElement);
|
||||||
|
const indicator = screen.getByTestId("status-indicator");
|
||||||
|
expect(indicator).toHaveAttribute("data-status", "spawning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows completed indicator for completed status", () => {
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<AgentTerminal agent={makeAgent({ status: "completed", endedAt: Date.now() })} />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
const indicator = screen.getByTestId("status-indicator");
|
||||||
|
expect(indicator).toHaveAttribute("data-status", "completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows error indicator for error status", () => {
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<AgentTerminal agent={makeAgent({ status: "error", endedAt: Date.now() })} />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
const indicator = screen.getByTestId("status-indicator");
|
||||||
|
expect(indicator).toHaveAttribute("data-status", "error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Status badge
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("status badge", () => {
|
||||||
|
it("renders the status badge", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("status-badge")).toHaveTextContent("running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'spawning' badge for spawning status", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent({ status: "spawning" })} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("status-badge")).toHaveTextContent("spawning");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'completed' badge for completed status", () => {
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<AgentTerminal agent={makeAgent({ status: "completed", endedAt: Date.now() })} />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("status-badge")).toHaveTextContent("completed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'error' badge for error status", () => {
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<AgentTerminal agent={makeAgent({ status: "error", endedAt: Date.now() })} />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("status-badge")).toHaveTextContent("error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Output rendering
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("output area", () => {
|
||||||
|
it("renders the output pre element", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("agent-output")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Waiting for output...' when outputLines is empty and status is running", () => {
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<AgentTerminal agent={makeAgent({ status: "running", outputLines: [] })} />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("agent-output")).toHaveTextContent("Waiting for output...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'Spawning agent...' when status is spawning and no output", () => {
|
||||||
|
render(
|
||||||
|
(
|
||||||
|
<AgentTerminal agent={makeAgent({ status: "spawning", outputLines: [] })} />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("agent-output")).toHaveTextContent("Spawning agent...");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders output lines as text content", () => {
|
||||||
|
const agent = makeAgent({
|
||||||
|
outputLines: ["Hello world\n", "Second line\n"],
|
||||||
|
});
|
||||||
|
render((<AgentTerminal agent={agent} />) as ReactElement);
|
||||||
|
const output = screen.getByTestId("agent-output");
|
||||||
|
expect(output).toHaveTextContent("Hello world");
|
||||||
|
expect(output).toHaveTextContent("Second line");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips ANSI escape codes from output", () => {
|
||||||
|
const agent = makeAgent({
|
||||||
|
outputLines: ["\x1b[32mGreen text\x1b[0m\n"],
|
||||||
|
});
|
||||||
|
render((<AgentTerminal agent={agent} />) as ReactElement);
|
||||||
|
const output = screen.getByTestId("agent-output");
|
||||||
|
expect(output).toHaveTextContent("Green text");
|
||||||
|
expect(output.textContent).not.toContain("\x1b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has aria-live=polite for screen reader announcements", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("agent-output")).toHaveAttribute("aria-live", "polite");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Error message
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("error message", () => {
|
||||||
|
it("shows error message when status is error and errorMessage is set", () => {
|
||||||
|
const agent = makeAgent({
|
||||||
|
status: "error",
|
||||||
|
endedAt: Date.now(),
|
||||||
|
errorMessage: "Process crashed",
|
||||||
|
});
|
||||||
|
render((<AgentTerminal agent={agent} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("agent-error-message")).toHaveTextContent("Process crashed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders alert role for error message", () => {
|
||||||
|
const agent = makeAgent({
|
||||||
|
status: "error",
|
||||||
|
endedAt: Date.now(),
|
||||||
|
errorMessage: "OOM killed",
|
||||||
|
});
|
||||||
|
render((<AgentTerminal agent={agent} />) as ReactElement);
|
||||||
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show error message when status is running", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent({ status: "running" })} />) as ReactElement);
|
||||||
|
expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show error message when status is error but errorMessage is absent", () => {
|
||||||
|
const agent = makeAgent({ status: "error", endedAt: Date.now() });
|
||||||
|
render((<AgentTerminal agent={agent} />) as ReactElement);
|
||||||
|
expect(screen.queryByTestId("agent-error-message")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Copy to clipboard
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("copy to clipboard", () => {
|
||||||
|
it("renders the copy button", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
||||||
|
expect(screen.getByTestId("copy-button")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copy button has aria-label='Copy agent output'", () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
||||||
|
expect(screen.getByRole("button", { name: "Copy agent output" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls clipboard.writeText with stripped output on click", async () => {
|
||||||
|
const agent = makeAgent({
|
||||||
|
outputLines: ["\x1b[32mLine 1\x1b[0m\n", "Line 2\n"],
|
||||||
|
});
|
||||||
|
render((<AgentTerminal agent={agent} />) as ReactElement);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByTestId("copy-button"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockWriteText).toHaveBeenCalledWith("Line 1\nLine 2\n");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'copied' text briefly after clicking copy", async () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByTestId("copy-button"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("copy-button")).toHaveTextContent("copied");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reverts copy button text after timeout", async () => {
|
||||||
|
render((<AgentTerminal agent={makeAgent()} />) as ReactElement);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByTestId("copy-button"));
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("copy-button")).toHaveTextContent("copy");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Auto-scroll
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("auto-scroll", () => {
|
||||||
|
it("does not throw when outputLines changes", () => {
|
||||||
|
const agent = makeAgent({ outputLines: ["Line 1\n"] });
|
||||||
|
const { rerender } = render((<AgentTerminal agent={agent} />) as ReactElement);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
rerender(
|
||||||
|
(
|
||||||
|
<AgentTerminal agent={{ ...agent, outputLines: ["Line 1\n", "Line 2\n"] }} />
|
||||||
|
) as ReactElement
|
||||||
|
);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
381
apps/web/src/components/terminal/AgentTerminal.tsx
Normal file
381
apps/web/src/components/terminal/AgentTerminal.tsx
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AgentTerminal component
|
||||||
|
*
|
||||||
|
* Read-only terminal view for displaying orchestrator agent output.
|
||||||
|
* Uses a <pre> element with monospace font rather than xterm.js because
|
||||||
|
* this is read-only agent stdout/stderr, not an interactive PTY.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - Displays accumulated output lines with basic ANSI color rendering
|
||||||
|
* - Status badge (spinning/checkmark/X) indicating agent lifecycle
|
||||||
|
* - Header bar with agent type, status, and elapsed duration
|
||||||
|
* - Auto-scrolls to bottom as new output arrives
|
||||||
|
* - Copy-to-clipboard button for full output
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState, useCallback } from "react";
|
||||||
|
import type { ReactElement, CSSProperties } from "react";
|
||||||
|
import type { AgentSession, AgentStatus } from "@/hooks/useAgentStream";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Types
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
export interface AgentTerminalProps {
|
||||||
|
/** The agent session to display */
|
||||||
|
agent: AgentSession;
|
||||||
|
/** Optional CSS class name for the outer container */
|
||||||
|
className?: string;
|
||||||
|
/** Optional inline style for the outer container */
|
||||||
|
style?: CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// ANSI color strip helper
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Simple ANSI escape sequence stripper — produces readable plain text for <pre>.
|
||||||
|
// We strip rather than parse for security and simplicity in read-only display.
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
const ANSI_PATTERN = /\x1b\[[0-9;]*[mGKHF]/g;
|
||||||
|
|
||||||
|
function stripAnsi(text: string): string {
|
||||||
|
return text.replace(ANSI_PATTERN, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Duration helper
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function formatDuration(startedAt: number, endedAt?: number): string {
|
||||||
|
const elapsed = Math.floor(((endedAt ?? Date.now()) - startedAt) / 1000);
|
||||||
|
if (elapsed < 60) return `${elapsed.toString()}s`;
|
||||||
|
const minutes = Math.floor(elapsed / 60);
|
||||||
|
const seconds = elapsed % 60;
|
||||||
|
return `${minutes.toString()}m ${seconds.toString()}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Status indicator
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
interface StatusIndicatorProps {
|
||||||
|
status: AgentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIndicator({ status }: StatusIndicatorProps): ReactElement {
|
||||||
|
const baseStyle: CSSProperties = {
|
||||||
|
display: "inline-block",
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: "50%",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === "running" || status === "spawning") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="status-indicator"
|
||||||
|
data-status={status}
|
||||||
|
style={{
|
||||||
|
...baseStyle,
|
||||||
|
background: "var(--success)",
|
||||||
|
animation: "agentPulse 1.5s ease-in-out infinite",
|
||||||
|
}}
|
||||||
|
aria-label="Running"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === "completed") {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="status-indicator"
|
||||||
|
data-status={status}
|
||||||
|
style={{
|
||||||
|
...baseStyle,
|
||||||
|
background: "var(--muted)",
|
||||||
|
}}
|
||||||
|
aria-label="Completed"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// error
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="status-indicator"
|
||||||
|
data-status={status}
|
||||||
|
style={{
|
||||||
|
...baseStyle,
|
||||||
|
background: "var(--danger)",
|
||||||
|
}}
|
||||||
|
aria-label="Error"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Status badge
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
interface StatusBadgeProps {
|
||||||
|
status: AgentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status }: StatusBadgeProps): ReactElement {
|
||||||
|
const colorMap: Record<AgentStatus, string> = {
|
||||||
|
spawning: "var(--warn)",
|
||||||
|
running: "var(--success)",
|
||||||
|
completed: "var(--muted)",
|
||||||
|
error: "var(--danger)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelMap: Record<AgentStatus, string> = {
|
||||||
|
spawning: "spawning",
|
||||||
|
running: "running",
|
||||||
|
completed: "completed",
|
||||||
|
error: "error",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="status-badge"
|
||||||
|
style={{
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: colorMap[status],
|
||||||
|
border: `1px solid ${colorMap[status]}`,
|
||||||
|
borderRadius: 3,
|
||||||
|
padding: "1px 5px",
|
||||||
|
lineHeight: 1.6,
|
||||||
|
letterSpacing: "0.03em",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{labelMap[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Component
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AgentTerminal renders accumulated agent output in a scrollable pre block.
|
||||||
|
* It is intentionally read-only — no keyboard input is accepted.
|
||||||
|
*/
|
||||||
|
export function AgentTerminal({ agent, className = "", style }: AgentTerminalProps): ReactElement {
|
||||||
|
const outputRef = useRef<HTMLPreElement>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Duration ticker — only runs while active
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (agent.status === "running" || agent.status === "spawning") {
|
||||||
|
const id = setInterval(() => {
|
||||||
|
setTick((t) => t + 1);
|
||||||
|
}, 1000);
|
||||||
|
return (): void => {
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [agent.status]);
|
||||||
|
|
||||||
|
// Consume tick to avoid unused-var lint
|
||||||
|
void tick;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Auto-scroll to bottom on new output
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = outputRef.current;
|
||||||
|
if (el) {
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [agent.outputLines]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Copy to clipboard
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const handleCopy = useCallback((): void => {
|
||||||
|
const text = agent.outputLines.map(stripAnsi).join("");
|
||||||
|
void navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}, [agent.outputLines]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Styles
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const containerStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
height: "100%",
|
||||||
|
background: "var(--bg-deep)",
|
||||||
|
overflow: "hidden",
|
||||||
|
...style,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headerStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 8,
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
background: "var(--bg-deep)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleStyle: CSSProperties = {
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--text)",
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
};
|
||||||
|
|
||||||
|
const durationStyle: CSSProperties = {
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--muted)",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const outputStyle: CSSProperties = {
|
||||||
|
flex: 1,
|
||||||
|
overflow: "auto",
|
||||||
|
margin: 0,
|
||||||
|
padding: "8px 12px",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
lineHeight: 1.5,
|
||||||
|
color: "var(--text)",
|
||||||
|
background: "var(--bg-deep)",
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
wordBreak: "break-all",
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyButtonStyle: CSSProperties = {
|
||||||
|
background: "transparent",
|
||||||
|
border: "1px solid var(--border)",
|
||||||
|
borderRadius: 3,
|
||||||
|
color: copied ? "var(--success)" : "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "0.65rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
padding: "2px 6px",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const duration = formatDuration(agent.startedAt, agent.endedAt);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={className}
|
||||||
|
style={containerStyle}
|
||||||
|
role="region"
|
||||||
|
aria-label={`Agent output: ${agent.agentType}`}
|
||||||
|
data-testid="agent-terminal"
|
||||||
|
data-agent-id={agent.agentId}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={headerStyle} data-testid="agent-terminal-header">
|
||||||
|
<StatusIndicator status={agent.status} />
|
||||||
|
|
||||||
|
<span style={titleStyle} data-testid="agent-type-label">
|
||||||
|
{agent.agentType}
|
||||||
|
{agent.jobId !== undefined ? ` · ${agent.jobId}` : ""}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<StatusBadge status={agent.status} />
|
||||||
|
|
||||||
|
<span style={durationStyle} data-testid="agent-duration">
|
||||||
|
{duration}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Copy button */}
|
||||||
|
<button
|
||||||
|
aria-label="Copy agent output"
|
||||||
|
style={copyButtonStyle}
|
||||||
|
onClick={handleCopy}
|
||||||
|
data-testid="copy-button"
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
if (!copied) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--text-2)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
if (!copied) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.borderColor = "var(--border)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "copied" : "copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output area */}
|
||||||
|
<pre
|
||||||
|
ref={outputRef}
|
||||||
|
style={outputStyle}
|
||||||
|
data-testid="agent-output"
|
||||||
|
aria-label="Agent output log"
|
||||||
|
aria-live="polite"
|
||||||
|
aria-atomic="false"
|
||||||
|
>
|
||||||
|
{agent.outputLines.length === 0 ? (
|
||||||
|
<span style={{ color: "var(--muted)" }}>
|
||||||
|
{agent.status === "spawning" ? "Spawning agent..." : "Waiting for output..."}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
agent.outputLines.map(stripAnsi).join("")
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
{/* Error message overlay */}
|
||||||
|
{agent.status === "error" && agent.errorMessage !== undefined && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "4px 12px",
|
||||||
|
fontSize: "0.7rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: "var(--danger)",
|
||||||
|
background: "var(--bg-deep)",
|
||||||
|
borderTop: "1px solid var(--border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
data-testid="agent-error-message"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
Error: {agent.errorMessage}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pulse animation keyframes — injected inline via style tag for zero deps */}
|
||||||
|
<style>{`
|
||||||
|
@keyframes agentPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
581
apps/web/src/components/terminal/TerminalPanel.test.tsx
Normal file
581
apps/web/src/components/terminal/TerminalPanel.test.tsx
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
/**
|
||||||
|
* @file TerminalPanel.test.tsx
|
||||||
|
* @description Unit tests for the TerminalPanel component — multi-tab scenarios
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Mocks
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Mock XTerminal to avoid xterm.js DOM dependencies in panel tests
|
||||||
|
vi.mock("./XTerminal", () => ({
|
||||||
|
XTerminal: vi.fn(
|
||||||
|
({
|
||||||
|
sessionId,
|
||||||
|
isVisible,
|
||||||
|
sessionStatus,
|
||||||
|
}: {
|
||||||
|
sessionId: string;
|
||||||
|
isVisible: boolean;
|
||||||
|
sessionStatus: string;
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-testid="mock-xterminal"
|
||||||
|
data-session-id={sessionId}
|
||||||
|
data-visible={isVisible ? "true" : "false"}
|
||||||
|
data-status={sessionStatus}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock AgentTerminal to avoid complexity in panel tests
|
||||||
|
vi.mock("./AgentTerminal", () => ({
|
||||||
|
AgentTerminal: vi.fn(
|
||||||
|
({ agent }: { agent: { agentId: string; agentType: string; status: string } }) => (
|
||||||
|
<div
|
||||||
|
data-testid="mock-agent-terminal"
|
||||||
|
data-agent-id={agent.agentId}
|
||||||
|
data-agent-type={agent.agentType}
|
||||||
|
data-status={agent.status}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useTerminalSessions
|
||||||
|
const mockCreateSession = vi.fn();
|
||||||
|
const mockCloseSession = vi.fn();
|
||||||
|
const mockRenameSession = vi.fn();
|
||||||
|
const mockSetActiveSession = vi.fn();
|
||||||
|
const mockSendInput = vi.fn();
|
||||||
|
const mockResize = vi.fn();
|
||||||
|
const mockRegisterOutputCallback = vi.fn(() => vi.fn());
|
||||||
|
|
||||||
|
// Mutable state for the mock — tests update these
|
||||||
|
let mockSessions = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
sessionId: string;
|
||||||
|
name: string;
|
||||||
|
status: "active" | "exited";
|
||||||
|
exitCode?: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
let mockActiveSessionId: string | null = null;
|
||||||
|
let mockIsConnected = false;
|
||||||
|
let mockConnectionError: string | null = null;
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useTerminalSessions", () => ({
|
||||||
|
useTerminalSessions: vi.fn(() => ({
|
||||||
|
sessions: mockSessions,
|
||||||
|
activeSessionId: mockActiveSessionId,
|
||||||
|
isConnected: mockIsConnected,
|
||||||
|
connectionError: mockConnectionError,
|
||||||
|
createSession: mockCreateSession,
|
||||||
|
closeSession: mockCloseSession,
|
||||||
|
renameSession: mockRenameSession,
|
||||||
|
setActiveSession: mockSetActiveSession,
|
||||||
|
sendInput: mockSendInput,
|
||||||
|
resize: mockResize,
|
||||||
|
registerOutputCallback: mockRegisterOutputCallback,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock useAgentStream
|
||||||
|
const mockDismissAgent = vi.fn();
|
||||||
|
let mockAgents = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
agentId: string;
|
||||||
|
agentType: string;
|
||||||
|
status: "spawning" | "running" | "completed" | "error";
|
||||||
|
outputLines: string[];
|
||||||
|
startedAt: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
let mockAgentStreamConnected = false;
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useAgentStream", () => ({
|
||||||
|
useAgentStream: vi.fn(() => ({
|
||||||
|
agents: mockAgents,
|
||||||
|
isConnected: mockAgentStreamConnected,
|
||||||
|
connectionError: null,
|
||||||
|
dismissAgent: mockDismissAgent,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { TerminalPanel } from "./TerminalPanel";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
function setTwoSessions(): void {
|
||||||
|
mockSessions = new Map([
|
||||||
|
["session-1", { sessionId: "session-1", name: "Terminal 1", status: "active" }],
|
||||||
|
["session-2", { sessionId: "session-2", name: "Terminal 2", status: "active" }],
|
||||||
|
]);
|
||||||
|
mockActiveSessionId = "session-1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tests
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("TerminalPanel", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockSessions = new Map();
|
||||||
|
mockActiveSessionId = null;
|
||||||
|
mockIsConnected = false;
|
||||||
|
mockConnectionError = null;
|
||||||
|
mockRegisterOutputCallback.mockReturnValue(vi.fn());
|
||||||
|
mockAgents = new Map();
|
||||||
|
mockAgentStreamConnected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Rendering
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders the terminal panel", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("region", { name: "Terminal panel" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with height 280 when open", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const panel = screen.getByRole("region", { name: "Terminal panel" });
|
||||||
|
expect(panel).toHaveStyle({ height: "280px" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders with height 0 when closed", () => {
|
||||||
|
const { container } = render(
|
||||||
|
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
||||||
|
);
|
||||||
|
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
|
||||||
|
expect(panel).toHaveStyle({ height: "0px" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders empty state when no sessions exist", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
// No XTerminal instances should be mounted
|
||||||
|
expect(screen.queryByTestId("mock-xterminal")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows connecting message in empty state when not connected", () => {
|
||||||
|
mockIsConnected = false;
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByText("Connecting...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows creating message in empty state when connected", () => {
|
||||||
|
mockIsConnected = true;
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByText("Creating terminal...")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tab bar from sessions
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("tab bar", () => {
|
||||||
|
it("renders a tab for each session", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("tab", { name: "Terminal 1" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("tab", { name: "Terminal 2" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the active session tab as selected", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
mockActiveSessionId = "session-2";
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"true"
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("tab", { name: "Terminal 1" })).toHaveAttribute(
|
||||||
|
"aria-selected",
|
||||||
|
"false"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls setActiveSession when a tab is clicked", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Terminal 2" }));
|
||||||
|
expect(mockSetActiveSession).toHaveBeenCalledWith("session-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has tablist role on the tab bar", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("tablist")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// New tab button
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("new tab button", () => {
|
||||||
|
it("renders the new tab button", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("button", { name: "New terminal tab" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls createSession when new tab button is clicked", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "New terminal tab" }));
|
||||||
|
expect(mockCreateSession).toHaveBeenCalledWith(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
expect.objectContaining({ name: expect.any(String) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Per-tab close button
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("per-tab close button", () => {
|
||||||
|
it("renders a close button for each tab", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("button", { name: "Close Terminal 1" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: "Close Terminal 2" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls closeSession with the correct sessionId when tab close is clicked", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close Terminal 1" }));
|
||||||
|
expect(mockCloseSession).toHaveBeenCalledWith("session-1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Panel close button
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("panel close button", () => {
|
||||||
|
it("renders the close panel button", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("button", { name: "Close terminal" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onClose when close panel button is clicked", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Close terminal" }));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Multi-tab XTerminal rendering
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("multi-tab terminal rendering", () => {
|
||||||
|
it("renders an XTerminal for each session", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const terminals = screen.getAllByTestId("mock-xterminal");
|
||||||
|
expect(terminals).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the active session terminal as visible", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
mockActiveSessionId = "session-1";
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const terminal1 = screen
|
||||||
|
.getAllByTestId("mock-xterminal")
|
||||||
|
.find((el) => el.getAttribute("data-session-id") === "session-1");
|
||||||
|
expect(terminal1).toHaveAttribute("data-visible", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides inactive session terminals", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
mockActiveSessionId = "session-1";
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const terminal2 = screen
|
||||||
|
.getAllByTestId("mock-xterminal")
|
||||||
|
.find((el) => el.getAttribute("data-session-id") === "session-2");
|
||||||
|
expect(terminal2).toHaveAttribute("data-visible", "false");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes sessionStatus to XTerminal", () => {
|
||||||
|
mockSessions = new Map([
|
||||||
|
[
|
||||||
|
"session-1",
|
||||||
|
{ sessionId: "session-1", name: "Terminal 1", status: "exited", exitCode: 0 },
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
mockActiveSessionId = "session-1";
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const terminal = screen.getByTestId("mock-xterminal");
|
||||||
|
expect(terminal).toHaveAttribute("data-status", "exited");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes isVisible=false to all terminals when panel is closed", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
const { container } = render(
|
||||||
|
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
||||||
|
);
|
||||||
|
const terminals = container.querySelectorAll('[data-testid="mock-xterminal"]');
|
||||||
|
terminals.forEach((terminal) => {
|
||||||
|
expect(terminal).toHaveAttribute("data-visible", "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Inline tab rename
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("tab rename", () => {
|
||||||
|
it("shows a rename input when a tab is double-clicked", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
||||||
|
expect(screen.getByTestId("tab-rename-input")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls renameSession when rename input loses focus", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
||||||
|
|
||||||
|
const input = screen.getByTestId("tab-rename-input");
|
||||||
|
fireEvent.change(input, { target: { value: "Custom Shell" } });
|
||||||
|
fireEvent.blur(input);
|
||||||
|
|
||||||
|
expect(mockRenameSession).toHaveBeenCalledWith("session-1", "Custom Shell");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls renameSession when Enter is pressed in the rename input", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
||||||
|
|
||||||
|
const input = screen.getByTestId("tab-rename-input");
|
||||||
|
fireEvent.change(input, { target: { value: "New Name" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Enter" });
|
||||||
|
|
||||||
|
expect(mockRenameSession).toHaveBeenCalledWith("session-1", "New Name");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cancels rename when Escape is pressed", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.dblClick(screen.getByRole("tab", { name: "Terminal 1" }));
|
||||||
|
|
||||||
|
const input = screen.getByTestId("tab-rename-input");
|
||||||
|
fireEvent.change(input, { target: { value: "Abandoned Name" } });
|
||||||
|
fireEvent.keyDown(input, { key: "Escape" });
|
||||||
|
|
||||||
|
expect(mockRenameSession).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByTestId("tab-rename-input")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Connection error banner
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("connection error", () => {
|
||||||
|
it("shows a connection error banner when connectionError is set", () => {
|
||||||
|
mockConnectionError = "WebSocket connection failed";
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const alert = screen.getByRole("alert");
|
||||||
|
expect(alert).toBeInTheDocument();
|
||||||
|
expect(alert).toHaveTextContent(/WebSocket connection failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show the error banner when connectionError is null", () => {
|
||||||
|
mockConnectionError = null;
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Accessibility
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("accessibility", () => {
|
||||||
|
it("has aria-hidden=true when closed", () => {
|
||||||
|
const { container } = render(
|
||||||
|
(<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement
|
||||||
|
);
|
||||||
|
const panel = container.querySelector('[role="region"][aria-label="Terminal panel"]');
|
||||||
|
expect(panel).toHaveAttribute("aria-hidden", "true");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has aria-hidden=false when open", () => {
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const panel = screen.getByRole("region", { name: "Terminal panel" });
|
||||||
|
expect(panel).toHaveAttribute("aria-hidden", "false");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Auto-create session
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("auto-create first session", () => {
|
||||||
|
it("calls createSession when connected and no sessions exist", () => {
|
||||||
|
mockIsConnected = true;
|
||||||
|
mockSessions = new Map();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(mockCreateSession).toHaveBeenCalledWith(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
expect.objectContaining({ name: expect.any(String) })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call createSession when sessions already exist", () => {
|
||||||
|
mockIsConnected = true;
|
||||||
|
setTwoSessions();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call createSession when not connected", () => {
|
||||||
|
mockIsConnected = false;
|
||||||
|
mockSessions = new Map();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call createSession when panel is closed", () => {
|
||||||
|
mockIsConnected = true;
|
||||||
|
mockSessions = new Map();
|
||||||
|
render((<TerminalPanel open={false} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(mockCreateSession).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Agent tab integration
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
describe("agent tab integration", () => {
|
||||||
|
function setOneAgent(status: "spawning" | "running" | "completed" | "error" = "running"): void {
|
||||||
|
mockAgents = new Map([
|
||||||
|
[
|
||||||
|
"agent-1",
|
||||||
|
{
|
||||||
|
agentId: "agent-1",
|
||||||
|
agentType: "worker",
|
||||||
|
status,
|
||||||
|
outputLines: ["Hello from agent\n"],
|
||||||
|
startedAt: Date.now() - 3000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("renders an agent tab when an agent is active", () => {
|
||||||
|
setOneAgent("running");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getAllByTestId("agent-tab")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders no agent tabs when agents map is empty", () => {
|
||||||
|
mockAgents = new Map();
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.queryByTestId("agent-tab")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent tab button has the agent type as label", () => {
|
||||||
|
setOneAgent("running");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent tab has role=tab", () => {
|
||||||
|
setOneAgent("running");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("tab", { name: "Agent: worker" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows dismiss button for completed agents", () => {
|
||||||
|
setOneAgent("completed");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows dismiss button for error agents", () => {
|
||||||
|
setOneAgent("error");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.getByRole("button", { name: "Dismiss worker agent" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show dismiss button for running agents", () => {
|
||||||
|
setOneAgent("running");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Dismiss worker agent" })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show dismiss button for spawning agents", () => {
|
||||||
|
setOneAgent("spawning");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Dismiss worker agent" })
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls dismissAgent when dismiss button is clicked", () => {
|
||||||
|
setOneAgent("completed");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Dismiss worker agent" }));
|
||||||
|
expect(mockDismissAgent).toHaveBeenCalledWith("agent-1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders AgentTerminal when agent tab is active", () => {
|
||||||
|
setOneAgent("running");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
// Click the agent tab to make it active
|
||||||
|
fireEvent.click(screen.getByRole("tab", { name: "Agent: worker" }));
|
||||||
|
// AgentTerminal should be rendered (mock shows mock-agent-terminal)
|
||||||
|
expect(screen.getByTestId("mock-agent-terminal")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a divider between terminal and agent tabs", () => {
|
||||||
|
setTwoSessions();
|
||||||
|
setOneAgent("running");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
// The divider div is aria-hidden; check it's present in the DOM
|
||||||
|
const tablist = screen.getByRole("tablist");
|
||||||
|
const divider = tablist.querySelector('[aria-hidden="true"][style*="width: 1"]');
|
||||||
|
expect(divider).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agent tabs show correct data-agent-status", () => {
|
||||||
|
setOneAgent("running");
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
const tab = screen.getByTestId("agent-tab");
|
||||||
|
expect(tab).toHaveAttribute("data-agent-status", "running");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("empty state not shown when agents exist but no terminal sessions", () => {
|
||||||
|
mockSessions = new Map();
|
||||||
|
setOneAgent("running");
|
||||||
|
mockIsConnected = false;
|
||||||
|
render((<TerminalPanel open={true} onClose={onClose} token="test-token" />) as ReactElement);
|
||||||
|
expect(screen.queryByText("Connecting...")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,80 +1,191 @@
|
|||||||
import type { ReactElement, CSSProperties } from "react";
|
"use client";
|
||||||
|
|
||||||
export interface TerminalLine {
|
/**
|
||||||
type: "prompt" | "command" | "output" | "error" | "warning" | "success";
|
* TerminalPanel
|
||||||
content: string;
|
*
|
||||||
}
|
* Multi-tab terminal panel. Manages multiple PTY sessions via useTerminalSessions,
|
||||||
|
* rendering one XTerminal per session and keeping all instances mounted (for
|
||||||
|
* scrollback preservation) while switching visibility with display:none.
|
||||||
|
*
|
||||||
|
* Also renders read-only agent output tabs from the orchestrator SSE stream
|
||||||
|
* via useAgentStream. Agent tabs are automatically added when agents are active
|
||||||
|
* and can be dismissed when completed or errored.
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - "+" button to open a new terminal tab
|
||||||
|
* - Per-tab close button (terminal) / dismiss button (agent)
|
||||||
|
* - Double-click tab label for inline rename (terminal tabs only)
|
||||||
|
* - Auto-creates the first terminal session on connect
|
||||||
|
* - Connection error state
|
||||||
|
* - Agent tabs: read-only, auto-appear, dismissable
|
||||||
|
*/
|
||||||
|
|
||||||
export interface TerminalTab {
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
id: string;
|
import type { ReactElement, CSSProperties, KeyboardEvent } from "react";
|
||||||
label: string;
|
import { XTerminal } from "./XTerminal";
|
||||||
}
|
import { AgentTerminal } from "./AgentTerminal";
|
||||||
|
import { useTerminalSessions } from "@/hooks/useTerminalSessions";
|
||||||
|
import { useAgentStream } from "@/hooks/useAgentStream";
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Types
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
export interface TerminalPanelProps {
|
export interface TerminalPanelProps {
|
||||||
|
/** Whether the panel is visible */
|
||||||
open: boolean;
|
open: boolean;
|
||||||
|
/** Called when the user closes the panel */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
tabs?: TerminalTab[];
|
/** Authentication token for the WebSocket connection */
|
||||||
activeTab?: string;
|
token?: string;
|
||||||
onTabChange?: (id: string) => void;
|
/** Optional CSS class name */
|
||||||
lines?: TerminalLine[];
|
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTabs: TerminalTab[] = [
|
// ==========================================
|
||||||
{ id: "main", label: "main" },
|
// Component
|
||||||
{ id: "build", label: "build" },
|
// ==========================================
|
||||||
{ id: "logs", label: "logs" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const blinkKeyframes = `
|
|
||||||
@keyframes ms-terminal-blink {
|
|
||||||
0%, 100% { opacity: 1; }
|
|
||||||
50% { opacity: 0; }
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
let blinkStyleInjected = false;
|
|
||||||
|
|
||||||
function ensureBlinkStyle(): void {
|
|
||||||
if (blinkStyleInjected || typeof document === "undefined") return;
|
|
||||||
const styleEl = document.createElement("style");
|
|
||||||
styleEl.textContent = blinkKeyframes;
|
|
||||||
document.head.appendChild(styleEl);
|
|
||||||
blinkStyleInjected = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLineColor(type: TerminalLine["type"]): string {
|
|
||||||
switch (type) {
|
|
||||||
case "prompt":
|
|
||||||
return "var(--success)";
|
|
||||||
case "command":
|
|
||||||
return "var(--text-2)";
|
|
||||||
case "output":
|
|
||||||
return "var(--muted)";
|
|
||||||
case "error":
|
|
||||||
return "var(--danger)";
|
|
||||||
case "warning":
|
|
||||||
return "var(--warn)";
|
|
||||||
case "success":
|
|
||||||
return "var(--success)";
|
|
||||||
default:
|
|
||||||
return "var(--muted)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TerminalPanel({
|
export function TerminalPanel({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
tabs,
|
token = "",
|
||||||
activeTab,
|
|
||||||
onTabChange,
|
|
||||||
lines = [],
|
|
||||||
className = "",
|
className = "",
|
||||||
}: TerminalPanelProps): ReactElement {
|
}: TerminalPanelProps): ReactElement {
|
||||||
ensureBlinkStyle();
|
const {
|
||||||
|
sessions,
|
||||||
|
activeSessionId,
|
||||||
|
isConnected,
|
||||||
|
connectionError,
|
||||||
|
createSession,
|
||||||
|
closeSession,
|
||||||
|
renameSession,
|
||||||
|
setActiveSession,
|
||||||
|
sendInput,
|
||||||
|
resize,
|
||||||
|
registerOutputCallback,
|
||||||
|
} = useTerminalSessions({ token });
|
||||||
|
|
||||||
const resolvedTabs = tabs ?? defaultTabs;
|
// ==========================================
|
||||||
const resolvedActiveTab = activeTab ?? resolvedTabs[0]?.id ?? "";
|
// Agent stream
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const { agents, dismissAgent } = useAgentStream();
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Active tab state (terminal session OR agent)
|
||||||
|
// "terminal:<sessionId>" or "agent:<agentId>"
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
type TabId = string; // prefix-qualified: "terminal:<id>" or "agent:<id>"
|
||||||
|
|
||||||
|
const [activeTabId, setActiveTabId] = useState<TabId | null>(null);
|
||||||
|
|
||||||
|
// Sync activeTabId with the terminal session activeSessionId when no agent tab is selected
|
||||||
|
useEffect(() => {
|
||||||
|
setActiveTabId((prev) => {
|
||||||
|
// If an agent tab is active, don't clobber it
|
||||||
|
if (prev?.startsWith("agent:")) return prev;
|
||||||
|
// Reflect active terminal session
|
||||||
|
if (activeSessionId !== null) return `terminal:${activeSessionId}`;
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}, [activeSessionId]);
|
||||||
|
|
||||||
|
// If the active agent tab is dismissed, fall back to the terminal session
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTabId?.startsWith("agent:")) {
|
||||||
|
const agentId = activeTabId.slice("agent:".length);
|
||||||
|
if (!agents.has(agentId)) {
|
||||||
|
setActiveTabId(activeSessionId !== null ? `terminal:${activeSessionId}` : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [agents, activeTabId, activeSessionId]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Inline rename state
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const [editingTabId, setEditingTabId] = useState<string | null>(null);
|
||||||
|
const [editingName, setEditingName] = useState("");
|
||||||
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Focus the rename input when editing starts
|
||||||
|
useEffect(() => {
|
||||||
|
if (editingTabId !== null) {
|
||||||
|
editInputRef.current?.select();
|
||||||
|
}
|
||||||
|
}, [editingTabId]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Auto-create first session on connect
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && isConnected && sessions.size === 0) {
|
||||||
|
createSession({ name: "Terminal 1" });
|
||||||
|
}
|
||||||
|
}, [open, isConnected, sessions.size, createSession]);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Tab rename helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const commitRename = useCallback((): void => {
|
||||||
|
if (editingTabId !== null) {
|
||||||
|
const trimmed = editingName.trim();
|
||||||
|
if (trimmed.length > 0) {
|
||||||
|
renameSession(editingTabId, trimmed);
|
||||||
|
}
|
||||||
|
setEditingTabId(null);
|
||||||
|
setEditingName("");
|
||||||
|
}
|
||||||
|
}, [editingTabId, editingName, renameSession]);
|
||||||
|
|
||||||
|
const handleTabDoubleClick = useCallback((sessionId: string, currentName: string): void => {
|
||||||
|
setEditingTabId(sessionId);
|
||||||
|
setEditingName(currentName);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRenameKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLInputElement>): void => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
commitRename();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
setEditingTabId(null);
|
||||||
|
setEditingName("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[commitRename]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Session control helpers
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
const handleCreateTab = useCallback((): void => {
|
||||||
|
const tabNumber = sessions.size + 1;
|
||||||
|
createSession({ name: `Terminal ${tabNumber.toString()}` });
|
||||||
|
}, [sessions.size, createSession]);
|
||||||
|
|
||||||
|
const handleCloseTab = useCallback(
|
||||||
|
(sessionId: string): void => {
|
||||||
|
closeSession(sessionId);
|
||||||
|
},
|
||||||
|
[closeSession]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRestart = useCallback(
|
||||||
|
(sessionId: string, name: string): void => {
|
||||||
|
closeSession(sessionId);
|
||||||
|
createSession({ name });
|
||||||
|
},
|
||||||
|
[closeSession, createSession]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Styles
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
const panelStyle: CSSProperties = {
|
const panelStyle: CSSProperties = {
|
||||||
background: "var(--bg-deep)",
|
background: "var(--bg-deep)",
|
||||||
@@ -99,33 +210,40 @@ export function TerminalPanel({
|
|||||||
const tabBarStyle: CSSProperties = {
|
const tabBarStyle: CSSProperties = {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 2,
|
gap: 2,
|
||||||
|
alignItems: "center",
|
||||||
|
flex: 1,
|
||||||
|
overflow: "hidden",
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionsStyle: CSSProperties = {
|
const actionsStyle: CSSProperties = {
|
||||||
marginLeft: "auto",
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
gap: 4,
|
gap: 4,
|
||||||
|
alignItems: "center",
|
||||||
};
|
};
|
||||||
|
|
||||||
const bodyStyle: CSSProperties = {
|
const bodyStyle: CSSProperties = {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
overflowY: "auto",
|
overflow: "hidden",
|
||||||
padding: "10px 16px",
|
display: "flex",
|
||||||
fontFamily: "var(--mono)",
|
flexDirection: "column",
|
||||||
fontSize: "0.78rem",
|
minHeight: 0,
|
||||||
lineHeight: 1.6,
|
position: "relative",
|
||||||
};
|
};
|
||||||
|
|
||||||
const cursorStyle: CSSProperties = {
|
// ==========================================
|
||||||
display: "inline-block",
|
// Agent status dot color
|
||||||
width: 7,
|
// ==========================================
|
||||||
height: 14,
|
|
||||||
background: "var(--success)",
|
const agentDotColor = (status: string): string => {
|
||||||
marginLeft: 2,
|
if (status === "running" || status === "spawning") return "var(--success)";
|
||||||
animation: "ms-terminal-blink 1s step-end infinite",
|
if (status === "error") return "var(--danger)";
|
||||||
verticalAlign: "text-bottom",
|
return "var(--muted)";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Render
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
className={className}
|
||||||
@@ -138,50 +256,315 @@ export function TerminalPanel({
|
|||||||
<div style={headerStyle}>
|
<div style={headerStyle}>
|
||||||
{/* Tab bar */}
|
{/* Tab bar */}
|
||||||
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
<div style={tabBarStyle} role="tablist" aria-label="Terminal tabs">
|
||||||
{resolvedTabs.map((tab) => {
|
{/* ---- Terminal session tabs ---- */}
|
||||||
const isActive = tab.id === resolvedActiveTab;
|
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
||||||
|
const tabKey = `terminal:${sessionId}`;
|
||||||
|
const isActive = tabKey === activeTabId;
|
||||||
|
const isEditing = sessionId === editingTabId;
|
||||||
|
|
||||||
const tabStyle: CSSProperties = {
|
const tabStyle: CSSProperties = {
|
||||||
padding: "3px 10px",
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
padding: "3px 6px 3px 10px",
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
fontSize: "0.75rem",
|
fontSize: "0.75rem",
|
||||||
fontFamily: "var(--mono)",
|
fontFamily: "var(--mono)",
|
||||||
color: isActive ? "var(--success)" : "var(--muted)",
|
color: isActive ? "var(--success)" : "var(--muted)",
|
||||||
cursor: "pointer",
|
|
||||||
background: isActive ? "var(--surface)" : "transparent",
|
background: isActive ? "var(--surface)" : "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
|
flexShrink: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<div key={tabKey} style={tabStyle}>
|
||||||
key={tab.id}
|
{isEditing ? (
|
||||||
role="tab"
|
<input
|
||||||
aria-selected={isActive}
|
ref={editInputRef}
|
||||||
style={tabStyle}
|
value={editingName}
|
||||||
onClick={(): void => {
|
onChange={(e): void => {
|
||||||
onTabChange?.(tab.id);
|
setEditingName(e.target.value);
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e): void => {
|
onBlur={commitRename}
|
||||||
if (!isActive) {
|
onKeyDown={handleRenameKeyDown}
|
||||||
|
data-testid="tab-rename-input"
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "1px solid var(--primary)",
|
||||||
|
borderRadius: 2,
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: "var(--text)",
|
||||||
|
width: `${Math.max(editingName.length, 4).toString()}ch`,
|
||||||
|
padding: "0 2px",
|
||||||
|
}}
|
||||||
|
aria-label="Rename terminal tab"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: isActive ? "var(--success)" : "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
}}
|
||||||
|
onClick={(): void => {
|
||||||
|
setActiveTabId(tabKey);
|
||||||
|
setActiveSession(sessionId);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(): void => {
|
||||||
|
handleTabDoubleClick(sessionId, sessionInfo.name);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={sessionInfo.name}
|
||||||
|
>
|
||||||
|
{sessionInfo.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-tab close button */}
|
||||||
|
<button
|
||||||
|
aria-label={`Close ${sessionInfo.name}`}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 3,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
padding: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onClick={(): void => {
|
||||||
|
handleCloseTab(sessionId);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
}
|
}}
|
||||||
}}
|
onMouseLeave={(e): void => {
|
||||||
onMouseLeave={(e): void => {
|
|
||||||
if (!isActive) {
|
|
||||||
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
}
|
}}
|
||||||
}}
|
>
|
||||||
|
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M1 1L7 7M7 1L1 7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* New tab button */}
|
||||||
|
<button
|
||||||
|
aria-label="New terminal tab"
|
||||||
|
style={{
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
borderRadius: 4,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
padding: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onClick={handleCreateTab}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Plus icon */}
|
||||||
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M6 1V11M1 6H11"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* ---- Agent section divider (only when agents exist) ---- */}
|
||||||
|
{agents.size > 0 && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
width: 1,
|
||||||
|
height: 16,
|
||||||
|
background: "var(--border)",
|
||||||
|
marginLeft: 6,
|
||||||
|
marginRight: 4,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---- Agent tabs ---- */}
|
||||||
|
{[...agents.entries()].map(([agentId, agentSession]) => {
|
||||||
|
const tabKey = `agent:${agentId}`;
|
||||||
|
const isActive = tabKey === activeTabId;
|
||||||
|
|
||||||
|
const canDismiss =
|
||||||
|
agentSession.status === "completed" || agentSession.status === "error";
|
||||||
|
|
||||||
|
const agentTabStyle: CSSProperties = {
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 4,
|
||||||
|
padding: "3px 6px 3px 8px",
|
||||||
|
borderRadius: 4,
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
color: isActive ? "var(--text)" : "var(--muted)",
|
||||||
|
background: isActive ? "var(--surface)" : "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tabKey}
|
||||||
|
style={agentTabStyle}
|
||||||
|
data-testid="agent-tab"
|
||||||
|
data-agent-id={agentId}
|
||||||
|
data-agent-status={agentSession.status}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{/* Status dot */}
|
||||||
</button>
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
display: "inline-block",
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
borderRadius: "50%",
|
||||||
|
background: agentDotColor(agentSession.status),
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Agent tab button — read-only, no rename */}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isActive}
|
||||||
|
style={{
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
color: isActive ? "var(--text)" : "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
padding: 0,
|
||||||
|
maxWidth: 100,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
onClick={(): void => {
|
||||||
|
setActiveTabId(tabKey);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text-2)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
if (!isActive) {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={`Agent: ${agentSession.agentType}`}
|
||||||
|
>
|
||||||
|
{agentSession.agentType}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dismiss button — only for completed/error agents */}
|
||||||
|
{canDismiss && (
|
||||||
|
<button
|
||||||
|
aria-label={`Dismiss ${agentSession.agentType} agent`}
|
||||||
|
style={{
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
borderRadius: 3,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
cursor: "pointer",
|
||||||
|
background: "transparent",
|
||||||
|
border: "none",
|
||||||
|
outline: "none",
|
||||||
|
padding: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onClick={(): void => {
|
||||||
|
dismissAgent(agentId);
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e): void => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M1 1L7 7M7 1L1 7"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div style={actionsStyle}>
|
<div style={actionsStyle}>
|
||||||
|
{/* Close panel button */}
|
||||||
<button
|
<button
|
||||||
aria-label="Close terminal"
|
aria-label="Close terminal"
|
||||||
style={{
|
style={{
|
||||||
@@ -208,7 +591,7 @@ export function TerminalPanel({
|
|||||||
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Close icon — simple X using SVG */}
|
{/* Close icon */}
|
||||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M1 1L11 11M11 1L1 11"
|
d="M1 1L11 11M11 1L1 11"
|
||||||
@@ -221,34 +604,90 @@ export function TerminalPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Connection error banner */}
|
||||||
<div style={bodyStyle} role="log" aria-live="polite" aria-label="Terminal output">
|
{connectionError !== null && (
|
||||||
{lines.map((line, index) => {
|
<div
|
||||||
const isLast = index === lines.length - 1;
|
role="alert"
|
||||||
const lineStyle: CSSProperties = {
|
style={{
|
||||||
display: "flex",
|
padding: "4px 16px",
|
||||||
gap: 8,
|
fontSize: "0.75rem",
|
||||||
};
|
fontFamily: "var(--mono)",
|
||||||
const contentStyle: CSSProperties = {
|
color: "var(--danger)",
|
||||||
color: getLineColor(line.type),
|
backgroundColor: "var(--bg-deep)",
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connection error: {connectionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Terminal body — keep all XTerminal instances mounted for scrollback */}
|
||||||
|
<div style={bodyStyle}>
|
||||||
|
{/* ---- Terminal session panels ---- */}
|
||||||
|
{[...sessions.entries()].map(([sessionId, sessionInfo]) => {
|
||||||
|
const tabKey = `terminal:${sessionId}`;
|
||||||
|
const isActive = tabKey === activeTabId;
|
||||||
|
const termStyle: CSSProperties = {
|
||||||
|
display: isActive ? "flex" : "none",
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} style={lineStyle}>
|
<div key={tabKey} style={termStyle}>
|
||||||
<span style={contentStyle}>
|
<XTerminal
|
||||||
{line.content}
|
sessionId={sessionId}
|
||||||
{isLast && <span aria-hidden="true" style={cursorStyle} />}
|
sendInput={sendInput}
|
||||||
</span>
|
resize={resize}
|
||||||
|
closeSession={closeSession}
|
||||||
|
registerOutputCallback={registerOutputCallback}
|
||||||
|
isConnected={isConnected}
|
||||||
|
sessionStatus={sessionInfo.status}
|
||||||
|
{...(sessionInfo.exitCode !== undefined ? { exitCode: sessionInfo.exitCode } : {})}
|
||||||
|
isVisible={isActive && open}
|
||||||
|
onRestart={(): void => {
|
||||||
|
handleRestart(sessionId, sessionInfo.name);
|
||||||
|
}}
|
||||||
|
style={{ flex: 1, minHeight: 0 }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Show cursor even when no lines */}
|
{/* ---- Agent session panels ---- */}
|
||||||
{lines.length === 0 && (
|
{[...agents.entries()].map(([agentId, agentSession]) => {
|
||||||
<div style={{ display: "flex", gap: 8 }}>
|
const tabKey = `agent:${agentId}`;
|
||||||
<span style={{ color: "var(--success)" }}>
|
const isActive = tabKey === activeTabId;
|
||||||
<span aria-hidden="true" style={cursorStyle} />
|
const agentPanelStyle: CSSProperties = {
|
||||||
</span>
|
display: isActive ? "flex" : "none",
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={tabKey} style={agentPanelStyle}>
|
||||||
|
<AgentTerminal agent={agentSession} style={{ flex: 1, minHeight: 0 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Empty state — show only when no terminal sessions AND no agent sessions */}
|
||||||
|
{sessions.size === 0 && agents.size === 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
fontSize: "0.75rem",
|
||||||
|
fontFamily: "var(--mono)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isConnected ? "Creating terminal..." : (connectionError ?? "Connecting...")}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user