Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Fixes CI pipeline failures caused by missing Prisma Client generation and TypeScript type safety issues. Added Prisma generation step to CI pipeline, installed missing type dependencies, and resolved 40+ exactOptionalPropertyTypes violations across service layer. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
210 lines
6.0 KiB
TypeScript
210 lines
6.0 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 { 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);
|
|
const workspaceId = user.workspaceId ?? (body.workspaceId as string | undefined);
|
|
|
|
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: 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
|
|
await this.activityService.logActivity({
|
|
workspaceId,
|
|
userId: user.id,
|
|
action,
|
|
entityType,
|
|
entityId,
|
|
details,
|
|
ipAddress: ip ?? undefined,
|
|
userAgent: userAgent ?? undefined,
|
|
});
|
|
} 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);
|
|
}
|
|
}
|