Files
stack/apps/api/src/activity/activity.service.ts
Jason Woltje 7e9022bf9b fix(CQ-API-3): Make activity logging fire-and-forget
Activity logging now catches and logs errors without propagating them.
This ensures activity logging failures never break primary operations.

Updated return type to ActivityLog | null to indicate potential failure.

Refs #339

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 19:26:34 -06:00

585 lines
13 KiB
TypeScript

import { Injectable, Logger } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityAction, EntityType, Prisma, ActivityLog } 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 (fire-and-forget)
*
* Activity logging failures are logged but never propagate to callers.
* This ensures activity logging never breaks primary operations.
*
* @returns The created ActivityLog or null if logging failed
*/
async logActivity(input: CreateActivityLogInput): Promise<ActivityLog | null> {
try {
return await this.prisma.activityLog.create({
data: input as unknown as Prisma.ActivityLogCreateInput,
});
} catch (error) {
// Log the error but don't propagate - activity logging is fire-and-forget
this.logger.error(
`Failed to log activity: action=${input.action} entityType=${input.entityType} entityId=${input.entityId}`,
error instanceof Error ? error.stack : String(error)
);
return null;
}
}
/**
* 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: Prisma.ActivityLogWhereInput = {};
if (query.workspaceId !== undefined) {
where.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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
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
): Promise<ActivityLog | null> {
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
): Promise<ActivityLog | null> {
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?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.USER,
entityId: userId,
...(details && { details }),
});
}
/**
* Log domain creation
*/
async logDomainCreated(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log domain update
*/
async logDomainUpdated(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log domain deletion
*/
async logDomainDeleted(
workspaceId: string,
userId: string,
domainId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.DOMAIN,
entityId: domainId,
...(details && { details }),
});
}
/**
* Log idea creation
*/
async logIdeaCreated(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.CREATED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
/**
* Log idea update
*/
async logIdeaUpdated(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.UPDATED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
/**
* Log idea deletion
*/
async logIdeaDeleted(
workspaceId: string,
userId: string,
ideaId: string,
details?: Prisma.JsonValue
): Promise<ActivityLog | null> {
return this.logActivity({
workspaceId,
userId,
action: ActivityAction.DELETED,
entityType: EntityType.IDEA,
entityId: ideaId,
...(details && { details }),
});
}
}