Files
stack/apps/api/src/activity/interceptors/activity-logging.interceptor.ts
Jason Woltje d361d00674
Some checks failed
ci/woodpecker/push/ci Pipeline failed
fix: Logs page — activity_logs, optional workspaceId, autoRefresh on (#637)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 22:10:16 +00:00

217 lines
6.4 KiB
TypeScript

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";
import type { Prisma } from "@prisma/client";
import type { CreateActivityLogInput } from "../interfaces/activity.interface";
import type { AuthenticatedRequest } from "../../common/types/user.types";
/**
* 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<unknown> {
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const { method, user } = 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((result: unknown): void => {
// Use void to satisfy no-misused-promises rule
void this.logActivity(context, request, result);
})
);
}
/**
* Logs activity asynchronously (not awaited to avoid blocking response)
*/
private async logActivity(
context: ExecutionContext,
request: AuthenticatedRequest,
result: unknown
): Promise<void> {
try {
const { method, params, body, user, ip, headers } = request;
if (!user) {
return;
}
const action = this.mapMethodToAction(method);
if (!action) {
return;
}
// Extract entity information
const resultObj = result as Record<string, unknown> | undefined;
const entityId = params.id ?? (resultObj?.id as string | undefined);
// workspaceId is now optional - log events even when missing
const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined);
// Log with warning if entityId is missing, but still proceed with logging if workspaceId exists
if (!entityId) {
this.logger.warn("Cannot log activity: missing entityId");
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: Prisma.JsonObject = {
method,
controller: controllerName,
handler: handlerName,
};
if (method === "POST") {
details.data = sanitizedBody;
} else if (method === "PATCH" || method === "PUT") {
details.changes = sanitizedBody;
}
// Extract user agent header
const userAgentHeader = headers["user-agent"];
const userAgent =
typeof userAgentHeader === "string" ? userAgentHeader : userAgentHeader?.[0];
// Log the activity — workspaceId is optional
const activityInput: CreateActivityLogInput = {
userId: user.id,
action,
entityType,
entityId,
details,
ipAddress: ip ?? undefined,
userAgent: userAgent ?? undefined,
};
if (workspaceId) {
activityInput.workspaceId = workspaceId;
}
await this.activityService.logActivity(activityInput);
} 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: unknown): Prisma.JsonValue {
if (typeof data !== "object" || data === null) {
return data as Prisma.JsonValue;
}
// 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: unknown): Prisma.JsonValue => {
if (Array.isArray(obj)) {
return obj.map((item) => sanitize(item)) as Prisma.JsonArray;
}
if (obj && typeof obj === "object") {
const sanitized: Prisma.JsonObject = {};
const objRecord = obj as Record<string, unknown>;
for (const key in objRecord) {
const lowerKey = key.toLowerCase();
const isSensitive = sensitiveFields.some((field) => lowerKey.includes(field));
if (isSensitive) {
sanitized[key] = "[REDACTED]";
} else if (typeof objRecord[key] === "object") {
sanitized[key] = sanitize(objRecord[key]);
} else {
sanitized[key] = objRecord[key] as Prisma.JsonValue;
}
}
return sanitized;
}
return obj as Prisma.JsonValue;
};
return sanitize(data);
}
}