diff --git a/apps/bff/src/core/http/transform.interceptor.ts b/apps/bff/src/core/http/transform.interceptor.ts new file mode 100644 index 00000000..32a00072 --- /dev/null +++ b/apps/bff/src/core/http/transform.interceptor.ts @@ -0,0 +1,75 @@ +import { + Injectable, + type NestInterceptor, + type ExecutionContext, + type CallHandler, +} from "@nestjs/common"; +import { SetMetadata } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { Observable, map } from "rxjs"; +import type { ApiSuccessResponse } from "@customer-portal/domain/common"; + +export const SKIP_SUCCESS_ENVELOPE_KEY = "bff:skip-success-envelope"; + +/** + * Opt-out decorator for endpoints that must not be wrapped in `{ success: true, data }`, + * e.g. SSE streams or file downloads. + */ +export const SkipSuccessEnvelope = () => SetMetadata(SKIP_SUCCESS_ENVELOPE_KEY, true); + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +function isLikelyStream(value: unknown): boolean { + // Avoid wrapping Node streams (file downloads / SSE internals). + return isRecord(value) && typeof (value as { pipe?: unknown }).pipe === "function"; +} + +@Injectable() +export class TransformInterceptor implements NestInterceptor> { + constructor(private readonly reflector: Reflector) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable> { + if (context.getType() !== "http") { + // Only wrap HTTP responses. + return next.handle() as unknown as Observable>; + } + + const skip = + this.reflector.getAllAndOverride(SKIP_SUCCESS_ENVELOPE_KEY, [ + context.getHandler(), + context.getClass(), + ]) ?? false; + + if (skip) { + return next.handle() as unknown as Observable>; + } + + const req = context.switchToHttp().getRequest<{ originalUrl?: string; url?: string }>(); + const url = req?.originalUrl ?? req?.url ?? ""; + + // Only enforce success envelopes on the public API surface under `/api`. + // Keep non-API endpoints (e.g. `/health`) untouched for operational tooling. + if (!url.startsWith("/api")) { + return next.handle() as unknown as Observable>; + } + + return next.handle().pipe( + map(data => { + // Keep already-wrapped responses as-is (ack/message/data variants). + if (isRecord(data) && "success" in data) { + return data as unknown as ApiSuccessResponse; + } + + // Avoid wrapping streams/buffers that are handled specially by Nest/Express. + if (isLikelyStream(data)) { + return data as unknown as ApiSuccessResponse; + } + + const normalized = (data === undefined ? null : data) as T; + return { success: true as const, data: normalized }; + }) + ); + } +}