feat(#37-41): Add domains, ideas, relationships, agents, widgets schema

Schema additions for issues #37-41:

New models:
- Domain (#37): Life domains (work, marriage, homelab, etc.)
- Idea (#38): Brain dumps with pgvector embeddings
- Relationship (#39): Generic entity linking (blocks, depends_on)
- Agent (#40): ClawdBot agent tracking with metrics
- AgentSession (#40): Conversation session tracking
- WidgetDefinition (#41): HUD widget registry
- UserLayout (#41): Per-user dashboard configuration

Updated models:
- Task, Event, Project: Added domainId foreign key
- User, Workspace: Added new relations

New enums:
- IdeaStatus: CAPTURED, PROCESSING, ACTIONABLE, ARCHIVED, DISCARDED
- RelationshipType: BLOCKS, BLOCKED_BY, DEPENDS_ON, etc.
- AgentStatus: IDLE, WORKING, WAITING, ERROR, TERMINATED
- EntityType: Added IDEA, DOMAIN

Migration: 20260129182803_add_domains_ideas_agents_widgets
This commit is contained in:
Jason Woltje
2026-01-29 12:29:21 -06:00
parent a220c2dc0a
commit 973502f26e
308 changed files with 18374 additions and 113 deletions

48
apps/api/.dockerignore Normal file
View File

@@ -0,0 +1,48 @@
# Node modules
node_modules
npm-debug.log
yarn-error.log
pnpm-debug.log
# Build output
dist
build
*.tsbuildinfo
# Tests
coverage
.vitest
test
*.spec.ts
*.test.ts
# Development files
.env
.env.*
!.env.example
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Documentation
README.md
docs
# Logs
logs
*.log
# Turbo
.turbo

103
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,103 @@
# Base image for all stages
FROM node:20-alpine AS base
# Install pnpm globally
RUN corepack enable && corepack prepare pnpm@10.19.0 --activate
# Set working directory
WORKDIR /app
# Copy monorepo configuration files
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY turbo.json ./
# ======================
# Dependencies stage
# ======================
FROM base AS deps
# Copy all package.json files for workspace resolution
COPY packages/shared/package.json ./packages/shared/
COPY packages/ui/package.json ./packages/ui/
COPY packages/config/package.json ./packages/config/
COPY apps/api/package.json ./apps/api/
# Install dependencies
RUN pnpm install --frozen-lockfile
# ======================
# Builder stage
# ======================
FROM base AS builder
# Copy dependencies
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages ./packages
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
# Copy all source code
COPY packages ./packages
COPY apps/api ./apps/api
# Set working directory to API app
WORKDIR /app/apps/api
# Generate Prisma client
RUN pnpm prisma:generate
# Build the application
RUN pnpm build
# ======================
# Production stage
# ======================
FROM node:20-alpine AS production
# Install pnpm
RUN corepack enable && corepack prepare pnpm@10.19.0 --activate
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nestjs -u 1001
WORKDIR /app
# Copy package files
COPY --chown=nestjs:nodejs pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY --chown=nestjs:nodejs turbo.json ./
# Copy package.json files for workspace resolution
COPY --chown=nestjs:nodejs packages/shared/package.json ./packages/shared/
COPY --chown=nestjs:nodejs packages/ui/package.json ./packages/ui/
COPY --chown=nestjs:nodejs packages/config/package.json ./packages/config/
COPY --chown=nestjs:nodejs apps/api/package.json ./apps/api/
# Install production dependencies only
RUN pnpm install --prod --frozen-lockfile
# Copy built application and dependencies
COPY --from=builder --chown=nestjs:nodejs /app/packages ./packages
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/dist ./apps/api/dist
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/prisma ./apps/api/prisma
COPY --from=builder --chown=nestjs:nodejs /app/apps/api/node_modules/.prisma ./apps/api/node_modules/.prisma
# Set working directory to API app
WORKDIR /app/apps/api
# Switch to non-root user
USER nestjs
# Expose API port
EXPOSE 3001
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
# Use dumb-init to handle signals properly
ENTRYPOINT ["dumb-init", "--"]
# Start the application
CMD ["node", "dist/main.js"]

View File

@@ -33,6 +33,8 @@
"@nestjs/platform-express": "^11.1.12",
"@prisma/client": "^6.19.2",
"better-auth": "^1.4.17",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -45,11 +47,12 @@
"@swc/core": "^1.10.18",
"@types/express": "^5.0.1",
"@types/node": "^22.13.4",
"@vitest/coverage-v8": "^4.0.18",
"express": "^5.2.1",
"prisma": "^6.19.2",
"tsx": "^4.21.0",
"typescript": "^5.8.2",
"unplugin-swc": "^1.5.2",
"vitest": "^3.0.8"
"vitest": "^4.0.18"
}
}

View File

@@ -0,0 +1,92 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "ActivityAction" ADD VALUE 'LOGIN';
ALTER TYPE "ActivityAction" ADD VALUE 'LOGOUT';
ALTER TYPE "ActivityAction" ADD VALUE 'PASSWORD_RESET';
ALTER TYPE "ActivityAction" ADD VALUE 'EMAIL_VERIFIED';
-- AlterTable
ALTER TABLE "activity_logs" ADD COLUMN "ip_address" TEXT,
ADD COLUMN "user_agent" TEXT;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "email_verified" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "image" TEXT;
-- CreateTable
CREATE TABLE "sessions" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"token" TEXT NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"ip_address" TEXT,
"user_agent" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "accounts" (
"id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"account_id" TEXT NOT NULL,
"provider_id" TEXT NOT NULL,
"access_token" TEXT,
"refresh_token" TEXT,
"id_token" TEXT,
"access_token_expires_at" TIMESTAMPTZ,
"refresh_token_expires_at" TIMESTAMPTZ,
"scope" TEXT,
"password" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verifications" (
"id" UUID NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expires_at" TIMESTAMPTZ NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "verifications_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE INDEX "sessions_user_id_idx" ON "sessions"("user_id");
-- CreateIndex
CREATE INDEX "sessions_token_idx" ON "sessions"("token");
-- CreateIndex
CREATE INDEX "accounts_user_id_idx" ON "accounts"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "accounts_provider_id_account_id_key" ON "accounts"("provider_id", "account_id");
-- CreateIndex
CREATE INDEX "verifications_identifier_idx" ON "verifications"("identifier");
-- CreateIndex
CREATE INDEX "activity_logs_action_idx" ON "activity_logs"("action");
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "accounts" ADD CONSTRAINT "accounts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,286 @@
-- CreateEnum
CREATE TYPE "IdeaStatus" AS ENUM ('CAPTURED', 'PROCESSING', 'ACTIONABLE', 'ARCHIVED', 'DISCARDED');
-- CreateEnum
CREATE TYPE "RelationshipType" AS ENUM ('BLOCKS', 'BLOCKED_BY', 'DEPENDS_ON', 'PARENT_OF', 'CHILD_OF', 'RELATED_TO', 'DUPLICATE_OF', 'SUPERSEDES', 'PART_OF');
-- CreateEnum
CREATE TYPE "AgentStatus" AS ENUM ('IDLE', 'WORKING', 'WAITING', 'ERROR', 'TERMINATED');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "EntityType" ADD VALUE 'IDEA';
ALTER TYPE "EntityType" ADD VALUE 'DOMAIN';
-- DropIndex
DROP INDEX "memory_embeddings_embedding_idx";
-- AlterTable
ALTER TABLE "events" ADD COLUMN "domain_id" UUID;
-- AlterTable
ALTER TABLE "projects" ADD COLUMN "domain_id" UUID;
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "domain_id" UUID;
-- CreateTable
CREATE TABLE "domains" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"color" TEXT,
"icon" TEXT,
"sort_order" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB NOT NULL DEFAULT '{}',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "domains_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ideas" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"domain_id" UUID,
"project_id" UUID,
"title" TEXT,
"content" TEXT NOT NULL,
"status" "IdeaStatus" NOT NULL DEFAULT 'CAPTURED',
"priority" "TaskPriority" NOT NULL DEFAULT 'MEDIUM',
"category" TEXT,
"tags" TEXT[],
"metadata" JSONB NOT NULL DEFAULT '{}',
"embedding" vector(1536),
"creator_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "ideas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "relationships" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"source_type" "EntityType" NOT NULL,
"source_id" UUID NOT NULL,
"target_type" "EntityType" NOT NULL,
"target_id" UUID NOT NULL,
"relationship" "RelationshipType" NOT NULL,
"metadata" JSONB NOT NULL DEFAULT '{}',
"notes" TEXT,
"creator_id" UUID NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "relationships_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agents" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"agent_id" TEXT NOT NULL,
"name" TEXT,
"model" TEXT,
"role" TEXT,
"status" "AgentStatus" NOT NULL DEFAULT 'IDLE',
"current_task" TEXT,
"metrics" JSONB NOT NULL DEFAULT '{"totalTasks": 0, "successfulTasks": 0, "failedTasks": 0, "avgResponseTimeMs": 0}',
"last_heartbeat" TIMESTAMPTZ,
"error_count" INTEGER NOT NULL DEFAULT 0,
"last_error" TEXT,
"fired_count" INTEGER NOT NULL DEFAULT 0,
"fire_history" JSONB NOT NULL DEFAULT '[]',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
"terminated_at" TIMESTAMPTZ,
CONSTRAINT "agents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_sessions" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"agent_id" UUID,
"session_key" TEXT NOT NULL,
"label" TEXT,
"channel" TEXT,
"context_summary" TEXT,
"message_count" INTEGER NOT NULL DEFAULT 0,
"is_active" BOOLEAN NOT NULL DEFAULT true,
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"last_message_at" TIMESTAMPTZ,
"ended_at" TIMESTAMPTZ,
"metadata" JSONB NOT NULL DEFAULT '{}',
CONSTRAINT "agent_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "widget_definitions" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"display_name" TEXT NOT NULL,
"description" TEXT,
"component" TEXT NOT NULL,
"default_width" INTEGER NOT NULL DEFAULT 1,
"default_height" INTEGER NOT NULL DEFAULT 1,
"min_width" INTEGER NOT NULL DEFAULT 1,
"min_height" INTEGER NOT NULL DEFAULT 1,
"max_width" INTEGER,
"max_height" INTEGER,
"config_schema" JSONB NOT NULL DEFAULT '{}',
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "widget_definitions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_layouts" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"user_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"is_default" BOOLEAN NOT NULL DEFAULT false,
"layout" JSONB NOT NULL DEFAULT '[]',
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "user_layouts_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "domains_workspace_id_idx" ON "domains"("workspace_id");
-- CreateIndex
CREATE UNIQUE INDEX "domains_workspace_id_slug_key" ON "domains"("workspace_id", "slug");
-- CreateIndex
CREATE INDEX "ideas_workspace_id_idx" ON "ideas"("workspace_id");
-- CreateIndex
CREATE INDEX "ideas_workspace_id_status_idx" ON "ideas"("workspace_id", "status");
-- CreateIndex
CREATE INDEX "ideas_domain_id_idx" ON "ideas"("domain_id");
-- CreateIndex
CREATE INDEX "ideas_project_id_idx" ON "ideas"("project_id");
-- CreateIndex
CREATE INDEX "ideas_creator_id_idx" ON "ideas"("creator_id");
-- CreateIndex
CREATE INDEX "relationships_source_type_source_id_idx" ON "relationships"("source_type", "source_id");
-- CreateIndex
CREATE INDEX "relationships_target_type_target_id_idx" ON "relationships"("target_type", "target_id");
-- CreateIndex
CREATE INDEX "relationships_relationship_idx" ON "relationships"("relationship");
-- CreateIndex
CREATE UNIQUE INDEX "relationships_workspace_id_source_type_source_id_target_typ_key" ON "relationships"("workspace_id", "source_type", "source_id", "target_type", "target_id", "relationship");
-- CreateIndex
CREATE INDEX "agents_workspace_id_idx" ON "agents"("workspace_id");
-- CreateIndex
CREATE INDEX "agents_status_idx" ON "agents"("status");
-- CreateIndex
CREATE UNIQUE INDEX "agents_workspace_id_agent_id_key" ON "agents"("workspace_id", "agent_id");
-- CreateIndex
CREATE INDEX "agent_sessions_workspace_id_idx" ON "agent_sessions"("workspace_id");
-- CreateIndex
CREATE INDEX "agent_sessions_user_id_idx" ON "agent_sessions"("user_id");
-- CreateIndex
CREATE INDEX "agent_sessions_agent_id_idx" ON "agent_sessions"("agent_id");
-- CreateIndex
CREATE INDEX "agent_sessions_is_active_idx" ON "agent_sessions"("is_active");
-- CreateIndex
CREATE UNIQUE INDEX "agent_sessions_workspace_id_session_key_key" ON "agent_sessions"("workspace_id", "session_key");
-- CreateIndex
CREATE UNIQUE INDEX "widget_definitions_name_key" ON "widget_definitions"("name");
-- CreateIndex
CREATE INDEX "user_layouts_user_id_idx" ON "user_layouts"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "user_layouts_workspace_id_user_id_name_key" ON "user_layouts"("workspace_id", "user_id", "name");
-- CreateIndex
CREATE INDEX "events_domain_id_idx" ON "events"("domain_id");
-- CreateIndex
CREATE INDEX "projects_domain_id_idx" ON "projects"("domain_id");
-- CreateIndex
CREATE INDEX "tasks_domain_id_idx" ON "tasks"("domain_id");
-- AddForeignKey
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "events" ADD CONSTRAINT "events_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "projects" ADD CONSTRAINT "projects_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "domains" ADD CONSTRAINT "domains_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_domain_id_fkey" FOREIGN KEY ("domain_id") REFERENCES "domains"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ideas" ADD CONSTRAINT "ideas_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "relationships" ADD CONSTRAINT "relationships_creator_id_fkey" FOREIGN KEY ("creator_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agents" ADD CONSTRAINT "agents_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_sessions" ADD CONSTRAINT "agent_sessions_agent_id_fkey" FOREIGN KEY ("agent_id") REFERENCES "agents"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_layouts" ADD CONSTRAINT "user_layouts_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_layouts" ADD CONSTRAINT "user_layouts_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -52,6 +52,10 @@ enum ActivityAction {
COMPLETED
ASSIGNED
COMMENTED
LOGIN
LOGOUT
PASSWORD_RESET
EMAIL_VERIFIED
}
enum EntityType {
@@ -60,6 +64,36 @@ enum EntityType {
PROJECT
WORKSPACE
USER
IDEA
DOMAIN
}
enum IdeaStatus {
CAPTURED
PROCESSING
ACTIONABLE
ARCHIVED
DISCARDED
}
enum RelationshipType {
BLOCKS
BLOCKED_BY
DEPENDS_ON
PARENT_OF
CHILD_OF
RELATED_TO
DUPLICATE_OF
SUPERSEDES
PART_OF
}
enum AgentStatus {
IDLE
WORKING
WAITING
ERROR
TERMINATED
}
// ============================================
@@ -78,15 +112,19 @@ model User {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
ownedWorkspaces Workspace[] @relation("WorkspaceOwner")
workspaceMemberships WorkspaceMember[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
createdEvents Event[] @relation("EventCreator")
createdProjects Project[] @relation("ProjectCreator")
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
createdEvents Event[] @relation("EventCreator")
createdProjects Project[] @relation("ProjectCreator")
activityLogs ActivityLog[]
sessions Session[]
accounts Account[]
ideas Idea[] @relation("IdeaCreator")
relationships Relationship[] @relation("RelationshipCreator")
agentSessions AgentSession[]
userLayouts UserLayout[]
@@map("users")
}
@@ -107,6 +145,12 @@ model Workspace {
projects Project[]
activityLogs ActivityLog[]
memoryEmbeddings MemoryEmbedding[]
domains Domain[]
ideas Idea[]
relationships Relationship[]
agents Agent[]
agentSessions AgentSession[]
userLayouts UserLayout[]
@@index([ownerId])
@@map("workspaces")
@@ -139,6 +183,7 @@ model Task {
creatorId String @map("creator_id") @db.Uuid
projectId String? @map("project_id") @db.Uuid
parentId String? @map("parent_id") @db.Uuid
domainId String? @map("domain_id") @db.Uuid
sortOrder Int @default(0) @map("sort_order")
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@ -152,6 +197,7 @@ model Task {
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
parent Task? @relation("TaskSubtasks", fields: [parentId], references: [id], onDelete: Cascade)
subtasks Task[] @relation("TaskSubtasks")
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
@@index([workspaceId])
@@index([workspaceId, status])
@@ -159,6 +205,7 @@ model Task {
@@index([assigneeId])
@@index([projectId])
@@index([parentId])
@@index([domainId])
@@map("tasks")
}
@@ -174,6 +221,7 @@ model Event {
recurrence Json?
creatorId String @map("creator_id") @db.Uuid
projectId String? @map("project_id") @db.Uuid
domainId String? @map("domain_id") @db.Uuid
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
@@ -182,11 +230,13 @@ model Event {
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
creator User @relation("EventCreator", fields: [creatorId], references: [id], onDelete: Cascade)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
@@index([workspaceId])
@@index([workspaceId, startTime])
@@index([creatorId])
@@index([projectId])
@@index([domainId])
@@map("events")
}
@@ -199,6 +249,7 @@ model Project {
startDate DateTime? @map("start_date") @db.Date
endDate DateTime? @map("end_date") @db.Date
creatorId String @map("creator_id") @db.Uuid
domainId String? @map("domain_id") @db.Uuid
color String?
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@ -209,10 +260,13 @@ model Project {
creator User @relation("ProjectCreator", fields: [creatorId], references: [id], onDelete: Cascade)
tasks Task[]
events Event[]
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
ideas Idea[]
@@index([workspaceId])
@@index([workspaceId, status])
@@index([creatorId])
@@index([domainId])
@@map("projects")
}
@@ -224,6 +278,8 @@ model ActivityLog {
entityType EntityType @map("entity_type")
entityId String @map("entity_id") @db.Uuid
details Json @default("{}")
ipAddress String? @map("ip_address")
userAgent String? @map("user_agent")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
// Relations
@@ -234,6 +290,7 @@ model ActivityLog {
@@index([workspaceId, createdAt])
@@index([entityType, entityId])
@@index([userId])
@@index([action])
@@map("activity_logs")
}
@@ -256,6 +313,239 @@ model MemoryEmbedding {
@@map("memory_embeddings")
}
// ============================================
// NEW MODELS
// ============================================
model Domain {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
name String
slug String
description String? @db.Text
color String?
icon String?
sortOrder Int @default(0) @map("sort_order")
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
tasks Task[]
events Event[]
projects Project[]
ideas Idea[]
@@unique([workspaceId, slug])
@@index([workspaceId])
@@map("domains")
}
model Idea {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
domainId String? @map("domain_id") @db.Uuid
projectId String? @map("project_id") @db.Uuid
// Core fields
title String?
content String @db.Text
// Status
status IdeaStatus @default(CAPTURED)
priority TaskPriority @default(MEDIUM)
// Categorization
category String?
tags String[]
metadata Json @default("{}")
// Embedding for semantic search (pgvector)
embedding Unsupported("vector(1536)")?
// Audit
creatorId String @map("creator_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
domain Domain? @relation(fields: [domainId], references: [id], onDelete: SetNull)
project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull)
creator User @relation("IdeaCreator", fields: [creatorId], references: [id], onDelete: Cascade)
@@index([workspaceId])
@@index([workspaceId, status])
@@index([domainId])
@@index([projectId])
@@index([creatorId])
@@map("ideas")
}
model Relationship {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
// Source entity
sourceType EntityType @map("source_type")
sourceId String @map("source_id") @db.Uuid
// Target entity
targetType EntityType @map("target_type")
targetId String @map("target_id") @db.Uuid
// Relationship type
relationship RelationshipType
metadata Json @default("{}")
notes String? @db.Text
// Audit
creatorId String @map("creator_id") @db.Uuid
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
creator User @relation("RelationshipCreator", fields: [creatorId], references: [id], onDelete: Cascade)
// Prevent duplicate relationships
@@unique([workspaceId, sourceType, sourceId, targetType, targetId, relationship])
@@index([sourceType, sourceId])
@@index([targetType, targetId])
@@index([relationship])
@@map("relationships")
}
model Agent {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
// Identity
agentId String @map("agent_id")
name String?
model String?
role String?
// Status
status AgentStatus @default(IDLE)
currentTask String? @map("current_task") @db.Text
// Performance metrics
metrics Json @default("{\"totalTasks\": 0, \"successfulTasks\": 0, \"failedTasks\": 0, \"avgResponseTimeMs\": 0}")
// Health
lastHeartbeat DateTime? @map("last_heartbeat") @db.Timestamptz
errorCount Int @default(0) @map("error_count")
lastError String? @map("last_error") @db.Text
// Firing history
firedCount Int @default(0) @map("fired_count")
fireHistory Json @default("[]") @map("fire_history")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
terminatedAt DateTime? @map("terminated_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
sessions AgentSession[]
@@unique([workspaceId, agentId])
@@index([workspaceId])
@@index([status])
@@map("agents")
}
model AgentSession {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
userId String @map("user_id") @db.Uuid
agentId String? @map("agent_id") @db.Uuid
// Identity
sessionKey String @map("session_key")
label String?
channel String?
// Context
contextSummary String? @map("context_summary") @db.Text
messageCount Int @default(0) @map("message_count")
// Status
isActive Boolean @default(true) @map("is_active")
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz
lastMessageAt DateTime? @map("last_message_at") @db.Timestamptz
endedAt DateTime? @map("ended_at") @db.Timestamptz
metadata Json @default("{}")
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)
@@unique([workspaceId, sessionKey])
@@index([workspaceId])
@@index([userId])
@@index([agentId])
@@index([isActive])
@@map("agent_sessions")
}
model WidgetDefinition {
id String @id @default(uuid()) @db.Uuid
name String @unique
displayName String @map("display_name")
description String? @db.Text
component String
// Default size (grid units)
defaultWidth Int @default(1) @map("default_width")
defaultHeight Int @default(1) @map("default_height")
minWidth Int @default(1) @map("min_width")
minHeight Int @default(1) @map("min_height")
maxWidth Int? @map("max_width")
maxHeight Int? @map("max_height")
// Configuration schema (JSON Schema for widget config)
configSchema Json @default("{}") @map("config_schema")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@map("widget_definitions")
}
model UserLayout {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
userId String @map("user_id") @db.Uuid
name String
isDefault Boolean @default(false) @map("is_default")
// Layout configuration (array of widget placements)
layout Json @default("[]")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
// Relations
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([workspaceId, userId, name])
@@index([userId])
@@map("user_layouts")
}
// ============================================
// AUTHENTICATION MODELS (BetterAuth)
// ============================================

View File

@@ -0,0 +1,383 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ActivityController } from "./activity.controller";
import { ActivityService } from "./activity.service";
import { ActivityAction, EntityType } from "@prisma/client";
import type { QueryActivityLogDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import { ExecutionContext } from "@nestjs/common";
describe("ActivityController", () => {
let controller: ActivityController;
let service: ActivityService;
const mockActivityService = {
findAll: vi.fn(),
findOne: vi.fn(),
getAuditTrail: vi.fn(),
};
const mockAuthGuard = {
canActivate: vi.fn((context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
request.user = {
id: "user-123",
workspaceId: "workspace-123",
email: "test@example.com",
};
return true;
}),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [ActivityController],
providers: [
{
provide: ActivityService,
useValue: mockActivityService,
},
],
})
.overrideGuard(AuthGuard)
.useValue(mockAuthGuard)
.compile();
controller = module.get<ActivityController>(ActivityController);
service = module.get<ActivityService>(ActivityService);
vi.clearAllMocks();
});
describe("findAll", () => {
const mockPaginatedResult = {
data: [
{
id: "activity-1",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: {},
createdAt: new Date("2024-01-01"),
user: {
id: "user-123",
name: "Test User",
email: "test@example.com",
},
},
],
meta: {
total: 1,
page: 1,
limit: 50,
totalPages: 1,
},
};
const mockRequest = {
user: {
id: "user-123",
workspaceId: "workspace-123",
email: "test@example.com",
},
};
it("should return paginated activity logs using authenticated user's workspaceId", async () => {
const query: QueryActivityLogDto = {
workspaceId: "workspace-123",
page: 1,
limit: 50,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
const result = await controller.findAll(query, mockRequest);
expect(result).toEqual(mockPaginatedResult);
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
it("should handle query with filters", async () => {
const query: QueryActivityLogDto = {
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
page: 1,
limit: 10,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
await controller.findAll(query, mockRequest);
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
it("should handle query with date range", async () => {
const startDate = new Date("2024-01-01");
const endDate = new Date("2024-01-31");
const query: QueryActivityLogDto = {
workspaceId: "workspace-123",
startDate,
endDate,
page: 1,
limit: 50,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
await controller.findAll(query, mockRequest);
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
it("should use user's workspaceId even if query provides different one", async () => {
const query: QueryActivityLogDto = {
workspaceId: "different-workspace",
page: 1,
limit: 50,
};
mockActivityService.findAll.mockResolvedValue(mockPaginatedResult);
await controller.findAll(query, mockRequest);
// Should use authenticated user's workspaceId, not query's
expect(mockActivityService.findAll).toHaveBeenCalledWith({
...query,
workspaceId: "workspace-123",
});
});
});
describe("findOne", () => {
const mockActivity = {
id: "activity-123",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: {},
createdAt: new Date(),
user: {
id: "user-123",
name: "Test User",
email: "test@example.com",
},
};
const mockRequest = {
user: {
id: "user-123",
workspaceId: "workspace-123",
email: "test@example.com",
},
};
it("should return a single activity log using authenticated user's workspaceId", async () => {
mockActivityService.findOne.mockResolvedValue(mockActivity);
const result = await controller.findOne("activity-123", mockRequest);
expect(result).toEqual(mockActivity);
expect(mockActivityService.findOne).toHaveBeenCalledWith(
"activity-123",
"workspace-123"
);
});
it("should return null if activity not found", async () => {
mockActivityService.findOne.mockResolvedValue(null);
const result = await controller.findOne("nonexistent", mockRequest);
expect(result).toBeNull();
});
it("should throw error if user workspaceId is missing", async () => {
const requestWithoutWorkspace = {
user: {
id: "user-123",
email: "test@example.com",
},
};
await expect(
controller.findOne("activity-123", requestWithoutWorkspace)
).rejects.toThrow("User workspaceId not found");
});
});
describe("getAuditTrail", () => {
const mockAuditTrail = [
{
id: "activity-1",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: { title: "New Task" },
createdAt: new Date("2024-01-01"),
user: {
id: "user-123",
name: "Test User",
email: "test@example.com",
},
},
{
id: "activity-2",
workspaceId: "workspace-123",
userId: "user-456",
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: "task-123",
details: { title: "Updated Task" },
createdAt: new Date("2024-01-02"),
user: {
id: "user-456",
name: "Another User",
email: "another@example.com",
},
},
];
const mockRequest = {
user: {
id: "user-123",
workspaceId: "workspace-123",
email: "test@example.com",
},
};
it("should return audit trail for a task using authenticated user's workspaceId", async () => {
mockActivityService.getAuditTrail.mockResolvedValue(mockAuditTrail);
const result = await controller.getAuditTrail(
mockRequest,
EntityType.TASK,
"task-123"
);
expect(result).toEqual(mockAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
"workspace-123",
EntityType.TASK,
"task-123"
);
});
it("should return audit trail for an event", async () => {
const eventAuditTrail = [
{
id: "activity-3",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.EVENT,
entityId: "event-123",
details: {},
createdAt: new Date(),
user: {
id: "user-123",
name: "Test User",
email: "test@example.com",
},
},
];
mockActivityService.getAuditTrail.mockResolvedValue(eventAuditTrail);
const result = await controller.getAuditTrail(
mockRequest,
EntityType.EVENT,
"event-123"
);
expect(result).toEqual(eventAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
"workspace-123",
EntityType.EVENT,
"event-123"
);
});
it("should return audit trail for a project", async () => {
const projectAuditTrail = [
{
id: "activity-4",
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: EntityType.PROJECT,
entityId: "project-123",
details: {},
createdAt: new Date(),
user: {
id: "user-123",
name: "Test User",
email: "test@example.com",
},
},
];
mockActivityService.getAuditTrail.mockResolvedValue(projectAuditTrail);
const result = await controller.getAuditTrail(
mockRequest,
EntityType.PROJECT,
"project-123"
);
expect(result).toEqual(projectAuditTrail);
expect(mockActivityService.getAuditTrail).toHaveBeenCalledWith(
"workspace-123",
EntityType.PROJECT,
"project-123"
);
});
it("should return empty array if no audit trail found", async () => {
mockActivityService.getAuditTrail.mockResolvedValue([]);
const result = await controller.getAuditTrail(
mockRequest,
EntityType.WORKSPACE,
"workspace-999"
);
expect(result).toEqual([]);
});
it("should throw error if user workspaceId is missing", async () => {
const requestWithoutWorkspace = {
user: {
id: "user-123",
email: "test@example.com",
},
};
await expect(
controller.getAuditTrail(
requestWithoutWorkspace,
EntityType.TASK,
"task-123"
)
).rejects.toThrow("User workspaceId not found");
});
});
});

View File

@@ -0,0 +1,59 @@
import { Controller, Get, Query, Param, UseGuards, Request } from "@nestjs/common";
import { ActivityService } from "./activity.service";
import { EntityType } from "@prisma/client";
import type { QueryActivityLogDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
/**
* Controller for activity log endpoints
* All endpoints require authentication
*/
@Controller("activity")
@UseGuards(AuthGuard)
export class ActivityController {
constructor(private readonly activityService: ActivityService) {}
/**
* GET /api/activity
* Get paginated activity logs with optional filters
* workspaceId is extracted from authenticated user context
*/
@Get()
async findAll(@Query() query: QueryActivityLogDto, @Request() req: any) {
// Extract workspaceId from authenticated user
const workspaceId = req.user?.workspaceId || query.workspaceId;
return this.activityService.findAll({ ...query, workspaceId });
}
/**
* GET /api/activity/:id
* Get a single activity log by ID
* workspaceId is extracted from authenticated user context
*/
@Get(":id")
async findOne(@Param("id") id: string, @Request() req: any) {
const workspaceId = req.user?.workspaceId;
if (!workspaceId) {
throw new Error("User workspaceId not found");
}
return this.activityService.findOne(id, workspaceId);
}
/**
* GET /api/activity/audit/:entityType/:entityId
* Get audit trail for a specific entity
* workspaceId is extracted from authenticated user context
*/
@Get("audit/:entityType/:entityId")
async getAuditTrail(
@Request() req: any,
@Param("entityType") entityType: EntityType,
@Param("entityId") entityId: string
) {
const workspaceId = req.user?.workspaceId;
if (!workspaceId) {
throw new Error("User workspaceId not found");
}
return this.activityService.getAuditTrail(workspaceId, entityType, entityId);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { ActivityController } from "./activity.controller";
import { ActivityService } from "./activity.service";
import { PrismaModule } from "../prisma/prisma.module";
/**
* Module for activity logging and audit trail functionality
*/
@Module({
imports: [PrismaModule],
controllers: [ActivityController],
providers: [ActivityService],
exports: [ActivityService],
})
export class ActivityModule {}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,462 @@
import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityAction, EntityType } from "@prisma/client";
import type {
CreateActivityLogInput,
PaginatedActivityLogs,
ActivityLogResult,
} from "./interfaces/activity.interface";
import type { QueryActivityLogDto } from "./dto";
/**
* Service for managing activity logs and audit trails
*/
@Injectable()
export class ActivityService {
private readonly logger = new Logger(ActivityService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Create a new activity log entry
*/
async logActivity(input: CreateActivityLogInput) {
try {
return await this.prisma.activityLog.create({
data: input,
});
} catch (error) {
this.logger.error("Failed to log activity", error);
throw error;
}
}
/**
* Get paginated activity logs with filters
*/
async findAll(query: QueryActivityLogDto): Promise<PaginatedActivityLogs> {
const page = query.page || 1;
const limit = query.limit || 50;
const skip = (page - 1) * limit;
// Build where clause
const where: any = {
workspaceId: query.workspaceId,
};
if (query.userId) {
where.userId = query.userId;
}
if (query.action) {
where.action = query.action;
}
if (query.entityType) {
where.entityType = query.entityType;
}
if (query.entityId) {
where.entityId = query.entityId;
}
if (query.startDate || query.endDate) {
where.createdAt = {};
if (query.startDate) {
where.createdAt.gte = query.startDate;
}
if (query.endDate) {
where.createdAt.lte = query.endDate;
}
}
// Execute queries in parallel
const [data, total] = await Promise.all([
this.prisma.activityLog.findMany({
where,
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "desc",
},
skip,
take: limit,
}),
this.prisma.activityLog.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
totalPages: Math.ceil(total / limit),
},
};
}
/**
* Get a single activity log by ID
*/
async findOne(
id: string,
workspaceId: string
): Promise<ActivityLogResult | null> {
return await this.prisma.activityLog.findUnique({
where: {
id,
workspaceId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
});
}
/**
* Get audit trail for a specific entity
*/
async getAuditTrail(
workspaceId: string,
entityType: EntityType,
entityId: string
): Promise<ActivityLogResult[]> {
return await this.prisma.activityLog.findMany({
where: {
workspaceId,
entityType,
entityId,
},
include: {
user: {
select: {
id: true,
name: true,
email: true,
},
},
},
orderBy: {
createdAt: "asc",
},
});
}
// ============================================
// HELPER METHODS FOR COMMON ACTIVITY TYPES
// ============================================
/**
* Log task creation
*/
async logTaskCreated(
workspaceId: string,
userId: string,
taskId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task update
*/
async logTaskUpdated(
workspaceId: string,
userId: string,
taskId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task deletion
*/
async logTaskDeleted(
workspaceId: string,
userId: string,
taskId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task completion
*/
async logTaskCompleted(
workspaceId: string,
userId: string,
taskId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.COMPLETED,
entityType: EntityType.TASK,
entityId: taskId,
...(details && { details }),
});
}
/**
* Log task assignment
*/
async logTaskAssigned(
workspaceId: string,
userId: string,
taskId: string,
assigneeId: string
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.ASSIGNED,
entityType: EntityType.TASK,
entityId: taskId,
details: { assigneeId },
});
}
/**
* Log event creation
*/
async logEventCreated(
workspaceId: string,
userId: string,
eventId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.EVENT,
entityId: eventId,
...(details && { details }),
});
}
/**
* Log event update
*/
async logEventUpdated(
workspaceId: string,
userId: string,
eventId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.EVENT,
entityId: eventId,
...(details && { details }),
});
}
/**
* Log event deletion
*/
async logEventDeleted(
workspaceId: string,
userId: string,
eventId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.EVENT,
entityId: eventId,
...(details && { details }),
});
}
/**
* Log project creation
*/
async logProjectCreated(
workspaceId: string,
userId: string,
projectId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.PROJECT,
entityId: projectId,
...(details && { details }),
});
}
/**
* Log project update
*/
async logProjectUpdated(
workspaceId: string,
userId: string,
projectId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.PROJECT,
entityId: projectId,
...(details && { details }),
});
}
/**
* Log project deletion
*/
async logProjectDeleted(
workspaceId: string,
userId: string,
projectId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.PROJECT,
entityId: projectId,
...(details && { details }),
});
}
/**
* Log workspace creation
*/
async logWorkspaceCreated(
workspaceId: string,
userId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
...(details && { details }),
});
}
/**
* Log workspace update
*/
async logWorkspaceUpdated(
workspaceId: string,
userId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
...(details && { details }),
});
}
/**
* Log workspace member added
*/
async logWorkspaceMemberAdded(
workspaceId: string,
userId: string,
memberId: string,
role: string
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
details: { memberId, role },
});
}
/**
* Log workspace member removed
*/
async logWorkspaceMemberRemoved(
workspaceId: string,
userId: string,
memberId: string
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.WORKSPACE,
entityId: workspaceId,
details: { memberId },
});
}
/**
* Log user profile update
*/
async logUserUpdated(
workspaceId: string,
userId: string,
details?: Record<string, any>
) {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.USER,
entityId: userId,
...(details && { details }),
});
}
}

View File

@@ -0,0 +1,348 @@
import { describe, it, expect } from "vitest";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
import { CreateActivityLogDto } from "./create-activity-log.dto";
import { ActivityAction, EntityType } from "@prisma/client";
describe("CreateActivityLogDto", () => {
describe("required fields validation", () => {
it("should pass with all required fields valid", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when workspaceId is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const workspaceIdError = errors.find((e) => e.property === "workspaceId");
expect(workspaceIdError).toBeDefined();
});
it("should fail when userId is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const userIdError = errors.find((e) => e.property === "userId");
expect(userIdError).toBeDefined();
});
it("should fail when action is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const actionError = errors.find((e) => e.property === "action");
expect(actionError).toBeDefined();
});
it("should fail when entityType is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityTypeError = errors.find((e) => e.property === "entityType");
expect(entityTypeError).toBeDefined();
});
it("should fail when entityId is missing", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityIdError = errors.find((e) => e.property === "entityId");
expect(entityIdError).toBeDefined();
});
});
describe("UUID validation", () => {
it("should fail with invalid workspaceId UUID", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "invalid-uuid",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("workspaceId");
});
it("should fail with invalid userId UUID", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "not-a-uuid",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const userIdError = errors.find((e) => e.property === "userId");
expect(userIdError).toBeDefined();
});
it("should fail with invalid entityId UUID", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "bad-entity-id",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityIdError = errors.find((e) => e.property === "entityId");
expect(entityIdError).toBeDefined();
});
});
describe("enum validation", () => {
it("should pass with all valid ActivityAction values", async () => {
const actions = Object.values(ActivityAction);
for (const action of actions) {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
}
});
it("should fail with invalid action value", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: "INVALID_ACTION",
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const actionError = errors.find((e) => e.property === "action");
expect(actionError?.constraints?.isEnum).toBeDefined();
});
it("should pass with all valid EntityType values", async () => {
const entityTypes = Object.values(EntityType);
for (const entityType of entityTypes) {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
}
});
it("should fail with invalid entityType value", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: "INVALID_TYPE",
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const entityTypeError = errors.find((e) => e.property === "entityType");
expect(entityTypeError?.constraints?.isEnum).toBeDefined();
});
});
describe("optional fields validation", () => {
it("should pass with valid details object", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
details: {
field: "status",
oldValue: "TODO",
newValue: "IN_PROGRESS",
},
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with non-object details", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
details: "not an object",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const detailsError = errors.find((e) => e.property === "details");
expect(detailsError?.constraints?.isObject).toBeDefined();
});
it("should pass with valid ipAddress", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
ipAddress: "192.168.1.1",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should pass with valid IPv6 address", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
ipAddress: "2001:0db8:85a3:0000:0000:8a2e:0370:7334",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when ipAddress exceeds max length", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
ipAddress: "a".repeat(46),
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const ipError = errors.find((e) => e.property === "ipAddress");
expect(ipError?.constraints?.maxLength).toBeDefined();
});
it("should pass with valid userAgent", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail when userAgent exceeds max length", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
userAgent: "a".repeat(501),
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const userAgentError = errors.find((e) => e.property === "userAgent");
expect(userAgentError?.constraints?.maxLength).toBeDefined();
});
it("should pass when optional fields are not provided", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.CREATED,
entityType: EntityType.TASK,
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe("complete validation", () => {
it("should pass with all fields valid", async () => {
const dto = plainToInstance(CreateActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.PROJECT,
entityId: "550e8400-e29b-41d4-a716-446655440002",
details: {
changes: ["status", "priority"],
metadata: { source: "web-app" },
},
ipAddress: "10.0.0.1",
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,43 @@
import { ActivityAction, EntityType } from "@prisma/client";
import {
IsUUID,
IsEnum,
IsOptional,
IsObject,
IsString,
MaxLength,
} from "class-validator";
/**
* DTO for creating a new activity log entry
*/
export class CreateActivityLogDto {
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
workspaceId!: string;
@IsUUID("4", { message: "userId must be a valid UUID" })
userId!: string;
@IsEnum(ActivityAction, { message: "action must be a valid ActivityAction" })
action!: ActivityAction;
@IsEnum(EntityType, { message: "entityType must be a valid EntityType" })
entityType!: EntityType;
@IsUUID("4", { message: "entityId must be a valid UUID" })
entityId!: string;
@IsOptional()
@IsObject({ message: "details must be an object" })
details?: Record<string, unknown>;
@IsOptional()
@IsString({ message: "ipAddress must be a string" })
@MaxLength(45, { message: "ipAddress must not exceed 45 characters" })
ipAddress?: string;
@IsOptional()
@IsString({ message: "userAgent must be a string" })
@MaxLength(500, { message: "userAgent must not exceed 500 characters" })
userAgent?: string;
}

View File

@@ -0,0 +1,2 @@
export * from "./create-activity-log.dto";
export * from "./query-activity-log.dto";

View File

@@ -0,0 +1,254 @@
import { describe, it, expect } from "vitest";
import { validate } from "class-validator";
import { plainToInstance } from "class-transformer";
import { QueryActivityLogDto } from "./query-activity-log.dto";
import { ActivityAction, EntityType } from "@prisma/client";
describe("QueryActivityLogDto", () => {
describe("workspaceId validation", () => {
it("should pass with valid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "invalid-uuid",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("workspaceId");
expect(errors[0].constraints?.isUuid).toBeDefined();
});
it("should fail when workspaceId is missing", async () => {
const dto = plainToInstance(QueryActivityLogDto, {});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const workspaceIdError = errors.find((e) => e.property === "workspaceId");
expect(workspaceIdError).toBeDefined();
});
});
describe("userId validation", () => {
it("should pass with valid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "not-a-uuid",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("userId");
});
it("should pass when userId is not provided (optional)", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe("action validation", () => {
it("should pass with valid ActivityAction", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
action: ActivityAction.CREATED,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid action value", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
action: "INVALID_ACTION",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("action");
});
it("should pass when action is not provided (optional)", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
describe("entityType validation", () => {
it("should pass with valid EntityType", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityType: EntityType.TASK,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid entityType value", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityType: "INVALID_TYPE",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("entityType");
});
});
describe("entityId validation", () => {
it("should pass with valid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityId: "550e8400-e29b-41d4-a716-446655440002",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid UUID", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
entityId: "invalid-entity-id",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("entityId");
});
});
describe("date validation", () => {
it("should pass with valid ISO date strings", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it("should fail with invalid date format", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
startDate: "not-a-date",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
expect(errors[0].property).toBe("startDate");
});
});
describe("pagination validation", () => {
it("should pass with valid page and limit", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
page: "1",
limit: "50",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.page).toBe(1);
expect(dto.limit).toBe(50);
});
it("should fail when page is less than 1", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
page: "0",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const pageError = errors.find((e) => e.property === "page");
expect(pageError?.constraints?.min).toBeDefined();
});
it("should fail when limit exceeds 100", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
limit: "101",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError?.constraints?.max).toBeDefined();
});
it("should fail when page is not an integer", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
page: "1.5",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const pageError = errors.find((e) => e.property === "page");
expect(pageError?.constraints?.isInt).toBeDefined();
});
it("should fail when limit is not an integer", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
limit: "50.5",
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === "limit");
expect(limitError?.constraints?.isInt).toBeDefined();
});
});
describe("multiple filters", () => {
it("should pass with all valid filters combined", async () => {
const dto = plainToInstance(QueryActivityLogDto, {
workspaceId: "550e8400-e29b-41d4-a716-446655440000",
userId: "550e8400-e29b-41d4-a716-446655440001",
action: ActivityAction.UPDATED,
entityType: EntityType.PROJECT,
entityId: "550e8400-e29b-41d4-a716-446655440002",
startDate: "2024-01-01T00:00:00.000Z",
endDate: "2024-01-31T23:59:59.999Z",
page: "2",
limit: "25",
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,56 @@
import { ActivityAction, EntityType } from "@prisma/client";
import {
IsUUID,
IsEnum,
IsOptional,
IsInt,
Min,
Max,
IsDateString,
} from "class-validator";
import { Type } from "class-transformer";
/**
* DTO for querying activity logs with filters and pagination
*/
export class QueryActivityLogDto {
@IsUUID("4", { message: "workspaceId must be a valid UUID" })
workspaceId!: string;
@IsOptional()
@IsUUID("4", { message: "userId must be a valid UUID" })
userId?: string;
@IsOptional()
@IsEnum(ActivityAction, { message: "action must be a valid ActivityAction" })
action?: ActivityAction;
@IsOptional()
@IsEnum(EntityType, { message: "entityType must be a valid EntityType" })
entityType?: EntityType;
@IsOptional()
@IsUUID("4", { message: "entityId must be a valid UUID" })
entityId?: string;
@IsOptional()
@IsDateString({}, { message: "startDate must be a valid ISO 8601 date string" })
startDate?: Date;
@IsOptional()
@IsDateString({}, { message: "endDate must be a valid ISO 8601 date string" })
endDate?: Date;
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
@Min(1, { message: "page must be at least 1" })
page?: number;
@IsOptional()
@Type(() => Number)
@IsInt({ message: "limit must be an integer" })
@Min(1, { message: "limit must be at least 1" })
@Max(100, { message: "limit must not exceed 100" })
limit?: number;
}

View File

@@ -0,0 +1,772 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ActivityLoggingInterceptor } from "./activity-logging.interceptor";
import { ActivityService } from "../activity.service";
import { ExecutionContext, CallHandler } from "@nestjs/common";
import { of } from "rxjs";
import { ActivityAction, EntityType } from "@prisma/client";
describe("ActivityLoggingInterceptor", () => {
let interceptor: ActivityLoggingInterceptor;
let activityService: ActivityService;
const mockActivityService = {
logActivity: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ActivityLoggingInterceptor,
{
provide: ActivityService,
useValue: mockActivityService,
},
],
}).compile();
interceptor = module.get<ActivityLoggingInterceptor>(
ActivityLoggingInterceptor
);
activityService = module.get<ActivityService>(ActivityService);
vi.clearAllMocks();
});
const createMockExecutionContext = (
method: string,
params: any = {},
body: any = {},
user: any = null,
ip = "127.0.0.1",
userAgent = "test-agent"
): ExecutionContext => {
return {
switchToHttp: () => ({
getRequest: () => ({
method,
params,
body,
user,
ip,
headers: {
"user-agent": userAgent,
},
}),
}),
getClass: () => ({ name: "TestController" }),
getHandler: () => ({ name: "testMethod" }),
} as any;
};
const createMockCallHandler = (result: any = {}): CallHandler => {
return {
handle: () => of(result),
} as any;
};
describe("intercept", () => {
it("should not log if user is not authenticated", async () => {
const context = createMockExecutionContext("POST", {}, {}, null);
const next = createMockCallHandler();
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should log POST request as CREATE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
id: "task-123",
workspaceId: "workspace-123",
title: "New Task",
};
const context = createMockExecutionContext(
"POST",
{},
body,
user,
"127.0.0.1",
"Mozilla/5.0"
);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.CREATED,
entityType: expect.any(String),
entityId: "task-123",
details: expect.objectContaining({
method: "POST",
controller: "TestController",
handler: "testMethod",
}),
ipAddress: "127.0.0.1",
userAgent: "Mozilla/5.0",
});
resolve();
});
});
});
it("should log PATCH request as UPDATE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const params = {
id: "task-456",
};
const body = {
status: "IN_PROGRESS",
};
const result = {
id: "task-456",
workspaceId: "workspace-123",
status: "IN_PROGRESS",
};
const context = createMockExecutionContext("PATCH", params, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-124",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.UPDATED,
entityType: expect.any(String),
entityId: "task-456",
details: expect.objectContaining({
method: "PATCH",
changes: body,
}),
ipAddress: "127.0.0.1",
userAgent: "test-agent",
});
resolve();
});
});
});
it("should log PUT request as UPDATE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const params = {
id: "event-789",
};
const body = {
title: "Updated Event",
};
const result = {
id: "event-789",
workspaceId: "workspace-123",
title: "Updated Event",
};
const context = createMockExecutionContext("PUT", params, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-125",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.UPDATED,
entityType: expect.any(String),
entityId: "event-789",
details: expect.objectContaining({
method: "PUT",
}),
ipAddress: "127.0.0.1",
userAgent: "test-agent",
});
resolve();
});
});
});
it("should log DELETE request as DELETE action", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const params = {
id: "project-999",
};
const result = {
id: "project-999",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("DELETE", params, {}, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-126",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith({
workspaceId: "workspace-123",
userId: "user-123",
action: ActivityAction.DELETED,
entityType: expect.any(String),
entityId: "project-999",
details: expect.objectContaining({
method: "DELETE",
}),
ipAddress: "127.0.0.1",
userAgent: "test-agent",
});
resolve();
});
});
});
it("should not log GET requests", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("GET", {}, {}, user);
const next = createMockCallHandler({ data: [] });
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should extract entity ID from result if not in params", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
id: "task-new-123",
workspaceId: "workspace-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-127",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
entityId: "task-new-123",
})
);
resolve();
});
});
});
it("should handle errors gracefully", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, {}, user);
const next = createMockCallHandler({ id: "test-123" });
mockActivityService.logActivity.mockRejectedValue(
new Error("Logging failed")
);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not throw error, just log it
resolve();
});
});
});
});
describe("edge cases", () => {
it("should handle POST request with no id field in response", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
workspaceId: "workspace-123",
title: "New Task",
// No 'id' field in response
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when entityId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should handle user object missing workspaceId", async () => {
const user = {
id: "user-123",
// No workspaceId
};
const body = {
title: "New Task",
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when workspaceId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should handle body missing workspaceId when user also missing workspaceId", async () => {
const user = {
id: "user-123",
// No workspaceId
};
const body = {
title: "New Task",
// No workspaceId
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not call logActivity when workspaceId is missing
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should extract workspaceId from body when not in user object", async () => {
const user = {
id: "user-123",
// No workspaceId
};
const body = {
workspaceId: "workspace-from-body",
title: "New Task",
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: "workspace-from-body",
})
);
resolve();
});
});
});
it("should handle null result from handler", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("DELETE", { id: "task-123" }, {}, user);
const next = createMockCallHandler(null);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should still log activity with entityId from params
expect(mockActivityService.logActivity).toHaveBeenCalledWith(
expect.objectContaining({
entityId: "task-123",
workspaceId: "workspace-123",
})
);
resolve();
});
});
});
it("should handle undefined result from handler", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, { title: "New Task" }, user);
const next = createMockCallHandler(undefined);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not log when entityId cannot be determined
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should log warning when entityId is missing", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "New Task",
};
const result = {
workspaceId: "workspace-123",
title: "New Task",
// No 'id' field
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
resolve();
});
});
consoleSpy.mockRestore();
});
it("should log warning when workspaceId is missing", async () => {
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const user = {
id: "user-123",
// No workspaceId
};
const body = {
title: "New Task",
};
const result = {
id: "task-123",
title: "New Task",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
resolve();
});
});
consoleSpy.mockRestore();
});
it("should handle activity service throwing an error", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, {}, user);
const next = createMockCallHandler({ id: "test-123" });
const activityError = new Error("Activity logging failed");
mockActivityService.logActivity.mockRejectedValue(activityError);
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not throw error, just log it
resolve();
});
});
});
it("should handle OPTIONS requests", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("OPTIONS", {}, {}, user);
const next = createMockCallHandler({});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not log OPTIONS requests
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
it("should handle HEAD requests", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("HEAD", {}, {}, user);
const next = createMockCallHandler({});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
// Should not log HEAD requests
expect(mockActivityService.logActivity).not.toHaveBeenCalled();
resolve();
});
});
});
});
describe("sensitive data sanitization", () => {
it("should redact password field", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
username: "testuser",
password: "secret123",
email: "test@example.com",
};
const result = {
id: "user-456",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-123",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data.password).toBe("[REDACTED]");
expect(logCall.details.data.username).toBe("testuser");
expect(logCall.details.data.email).toBe("test@example.com");
resolve();
});
});
});
it("should redact token field", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "Integration",
apiToken: "sk_test_1234567890",
};
const result = {
id: "integration-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-124",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data.apiToken).toBe("[REDACTED]");
expect(logCall.details.data.title).toBe("Integration");
resolve();
});
});
});
it("should redact sensitive fields in nested objects", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "Config",
settings: {
apiKey: "secret_key",
public: "visible_data",
auth: {
token: "auth_token_123",
refreshToken: "refresh_token_456",
},
},
};
const result = {
id: "config-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-128",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data.title).toBe("Config");
expect(logCall.details.data.settings.apiKey).toBe("[REDACTED]");
expect(logCall.details.data.settings.public).toBe("visible_data");
expect(logCall.details.data.settings.auth.token).toBe("[REDACTED]");
expect(logCall.details.data.settings.auth.refreshToken).toBe(
"[REDACTED]"
);
resolve();
});
});
});
it("should not modify non-sensitive fields", async () => {
const user = {
id: "user-123",
workspaceId: "workspace-123",
};
const body = {
title: "Safe Data",
description: "This is public",
count: 42,
active: true,
};
const result = {
id: "item-123",
workspaceId: "workspace-123",
};
const context = createMockExecutionContext("POST", {}, body, user);
const next = createMockCallHandler(result);
mockActivityService.logActivity.mockResolvedValue({
id: "activity-130",
});
await new Promise<void>((resolve) => {
interceptor.intercept(context, next).subscribe(() => {
const logCall = mockActivityService.logActivity.mock.calls[0][0];
expect(logCall.details.data).toEqual(body);
resolve();
});
});
});
});
});

View File

@@ -0,0 +1,195 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
} from "@nestjs/common";
import { Observable } from "rxjs";
import { tap } from "rxjs/operators";
import { ActivityService } from "../activity.service";
import { ActivityAction, EntityType } from "@prisma/client";
/**
* Interceptor for automatic activity logging
* Logs CREATE, UPDATE, DELETE actions based on HTTP methods
*/
@Injectable()
export class ActivityLoggingInterceptor implements NestInterceptor {
private readonly logger = new Logger(ActivityLoggingInterceptor.name);
constructor(private readonly activityService: ActivityService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const { method, params, body, user, ip, headers } = request;
// Only log for authenticated requests
if (!user) {
return next.handle();
}
// Skip GET requests (read-only)
if (method === "GET") {
return next.handle();
}
return next.handle().pipe(
tap(async (result) => {
try {
const action = this.mapMethodToAction(method);
if (!action) {
return;
}
// Extract entity information
const entityId = params.id || result?.id;
const workspaceId = user.workspaceId || body.workspaceId;
if (!entityId || !workspaceId) {
this.logger.warn(
"Cannot log activity: missing entityId or workspaceId"
);
return;
}
// Determine entity type from controller/handler
const controllerName = context.getClass().name;
const handlerName = context.getHandler().name;
const entityType = this.inferEntityType(controllerName, handlerName);
// Build activity details with sanitized body
const sanitizedBody = this.sanitizeSensitiveData(body);
const details: Record<string, any> = {
method,
controller: controllerName,
handler: handlerName,
};
if (method === "POST") {
details.data = sanitizedBody;
} else if (method === "PATCH" || method === "PUT") {
details.changes = sanitizedBody;
}
// Log the activity
await this.activityService.logActivity({
workspaceId,
userId: user.id,
action,
entityType,
entityId,
details,
ipAddress: ip,
userAgent: headers["user-agent"],
});
} catch (error) {
// Don't fail the request if activity logging fails
this.logger.error(
"Failed to log activity",
error instanceof Error ? error.message : "Unknown error"
);
}
})
);
}
/**
* Map HTTP method to ActivityAction
*/
private mapMethodToAction(method: string): ActivityAction | null {
switch (method) {
case "POST":
return ActivityAction.CREATED;
case "PATCH":
case "PUT":
return ActivityAction.UPDATED;
case "DELETE":
return ActivityAction.DELETED;
default:
return null;
}
}
/**
* Infer entity type from controller/handler names
*/
private inferEntityType(
controllerName: string,
handlerName: string
): EntityType {
const combined = `${controllerName} ${handlerName}`.toLowerCase();
if (combined.includes("task")) {
return EntityType.TASK;
} else if (combined.includes("event")) {
return EntityType.EVENT;
} else if (combined.includes("project")) {
return EntityType.PROJECT;
} else if (combined.includes("workspace")) {
return EntityType.WORKSPACE;
} else if (combined.includes("user")) {
return EntityType.USER;
}
// Default to TASK if cannot determine
return EntityType.TASK;
}
/**
* Sanitize sensitive data from objects before logging
* Redacts common sensitive field names
*/
private sanitizeSensitiveData(data: any): any {
if (!data || typeof data !== "object") {
return data;
}
// List of sensitive field names (case-insensitive)
const sensitiveFields = [
"password",
"token",
"secret",
"apikey",
"api_key",
"authorization",
"creditcard",
"credit_card",
"cvv",
"ssn",
"privatekey",
"private_key",
];
const sanitize = (obj: any): any => {
if (Array.isArray(obj)) {
return obj.map((item) => sanitize(item));
}
if (obj && typeof obj === "object") {
const sanitized: Record<string, any> = {};
for (const key in obj) {
const lowerKey = key.toLowerCase();
const isSensitive = sensitiveFields.some((field) =>
lowerKey.includes(field)
);
if (isSensitive) {
sanitized[key] = "[REDACTED]";
} else if (typeof obj[key] === "object") {
sanitized[key] = sanitize(obj[key]);
} else {
sanitized[key] = obj[key];
}
}
return sanitized;
}
return obj;
};
return sanitize(data);
}
}

View File

@@ -0,0 +1,57 @@
import { ActivityAction, EntityType, Prisma } from "@prisma/client";
/**
* Interface for creating a new activity log entry
*/
export interface CreateActivityLogInput {
workspaceId: string;
userId: string;
action: ActivityAction;
entityType: EntityType;
entityId: string;
details?: Record<string, any>;
ipAddress?: string;
userAgent?: string;
}
/**
* Interface for activity log query filters
*/
export interface ActivityLogFilters {
workspaceId: string;
userId?: string;
action?: ActivityAction;
entityType?: EntityType;
entityId?: string;
startDate?: Date;
endDate?: Date;
}
/**
* Type for activity log result with user info
* Uses Prisma's generated type for type safety
*/
export type ActivityLogResult = Prisma.ActivityLogGetPayload<{
include: {
user: {
select: {
id: true;
name: true;
email: true;
};
};
};
}>;
/**
* Interface for paginated activity log results
*/
export interface PaginatedActivityLogs {
data: ActivityLogResult[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}

View File

@@ -1,4 +1,5 @@
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { AppModule } from "./app.module";
import { GlobalExceptionFilter } from "./filters/global-exception.filter";
@@ -27,6 +28,18 @@ function getPort(): number {
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable global validation pipe with transformation
app.useGlobalPipes(
new ValidationPipe({
transform: true,
whitelist: true,
forbidNonWhitelisted: false,
transformOptions: {
enableImplicitConversion: false,
},
})
);
app.useGlobalFilters(new GlobalExceptionFilter());
app.enableCors();