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:
383
apps/api/src/activity/activity.controller.spec.ts
Normal file
383
apps/api/src/activity/activity.controller.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
59
apps/api/src/activity/activity.controller.ts
Normal file
59
apps/api/src/activity/activity.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/activity/activity.module.ts
Normal file
15
apps/api/src/activity/activity.module.ts
Normal 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 {}
|
||||
1393
apps/api/src/activity/activity.service.spec.ts
Normal file
1393
apps/api/src/activity/activity.service.spec.ts
Normal file
File diff suppressed because it is too large
Load Diff
462
apps/api/src/activity/activity.service.ts
Normal file
462
apps/api/src/activity/activity.service.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
}
|
||||
348
apps/api/src/activity/dto/create-activity-log.dto.spec.ts
Normal file
348
apps/api/src/activity/dto/create-activity-log.dto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
43
apps/api/src/activity/dto/create-activity-log.dto.ts
Normal file
43
apps/api/src/activity/dto/create-activity-log.dto.ts
Normal 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;
|
||||
}
|
||||
2
apps/api/src/activity/dto/index.ts
Normal file
2
apps/api/src/activity/dto/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./create-activity-log.dto";
|
||||
export * from "./query-activity-log.dto";
|
||||
254
apps/api/src/activity/dto/query-activity-log.dto.spec.ts
Normal file
254
apps/api/src/activity/dto/query-activity-log.dto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
apps/api/src/activity/dto/query-activity-log.dto.ts
Normal file
56
apps/api/src/activity/dto/query-activity-log.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
57
apps/api/src/activity/interfaces/activity.interface.ts
Normal file
57
apps/api/src/activity/interfaces/activity.interface.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user