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

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();