diff --git a/eslint.config.mjs b/eslint.config.mjs index 145de21..99c2239 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -29,6 +29,14 @@ export default tseslint.config( "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-floating-promises": "warn", "@typescript-eslint/no-unsafe-argument": "warn", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + }, + ], + "no-unused-vars": "off", // Turn off base rule as it can report incorrect errors with TypeScript }, }, ); diff --git a/src/core/dtos/audit-log-response.dto.ts b/src/core/dtos/audit-log-response.dto.ts new file mode 100644 index 0000000..67c6ece --- /dev/null +++ b/src/core/dtos/audit-log-response.dto.ts @@ -0,0 +1,266 @@ +/** + * ============================================================================ + * AUDIT LOG RESPONSE DTO - OUTPUT FORMATTING + * ============================================================================ + * + * This file defines the Data Transfer Object (DTO) for audit log responses. + * It ensures consistent output format across all API endpoints. + * + * Purpose: + * - Define the structure of audit log responses sent to clients + * - Provide type-safe API response objects + * - Support pagination metadata in list responses + * + * Note: This DTO is primarily for documentation and type safety. + * The actual AuditLog entity from types.ts is already well-structured + * for output, so this DTO closely mirrors it. + * + * @packageDocumentation + */ + +import { z } from "zod"; + +import { ActorType, AuditActionType } from "../types"; + +// ============================================================================ +// RESPONSE SCHEMAS - Mirror the Core Types +// ============================================================================ + +/** + * Schema for Actor in response data. + */ +export const ActorResponseSchema = z.object({ + id: z.string(), + type: z.nativeEnum(ActorType), + name: z.string().optional(), + email: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +/** + * Schema for Resource in response data. + */ +export const ResourceResponseSchema = z.object({ + type: z.string(), + id: z.string(), + label: z.string().optional(), + metadata: z.record(z.unknown()).optional(), +}); + +/** + * Schema for a single field change in response data. + */ +export const FieldChangeResponseSchema = z.object({ + from: z.unknown(), + to: z.unknown(), +}); + +// ============================================================================ +// MAIN RESPONSE DTO SCHEMA +// ============================================================================ + +/** + * Zod schema for a single audit log in API responses. + * + * This matches the AuditLog entity structure but is explicitly + * defined here for API contract documentation and validation. + */ +export const AuditLogResponseDtoSchema = z.object({ + // ───────────────────────────────────────────────────────────────────────── + // IDENTITY + // ───────────────────────────────────────────────────────────────────────── + + /** Unique identifier for the audit log */ + id: z.string(), + + /** When the action occurred (ISO 8601 string in responses) */ + timestamp: z + .date() + .or(z.string().datetime()) + .transform((val) => (val instanceof Date ? val.toISOString() : val)), + + // ───────────────────────────────────────────────────────────────────────── + // WHO - Actor Information + // ───────────────────────────────────────────────────────────────────────── + + /** The entity that performed the action */ + actor: ActorResponseSchema, + + // ───────────────────────────────────────────────────────────────────────── + // WHAT - Action Information + // ───────────────────────────────────────────────────────────────────────── + + /** The type of action performed */ + action: z.union([z.nativeEnum(AuditActionType), z.string()]), + + /** Optional description of the action */ + actionDescription: z.string().optional(), + + // ───────────────────────────────────────────────────────────────────────── + // WHAT WAS AFFECTED - Resource Information + // ───────────────────────────────────────────────────────────────────────── + + /** The resource that was affected */ + resource: ResourceResponseSchema, + + // ───────────────────────────────────────────────────────────────────────── + // DETAILS - Changes and Metadata + // ───────────────────────────────────────────────────────────────────────── + + /** Field-level changes (for UPDATE actions) */ + changes: z.record(FieldChangeResponseSchema).optional(), + + /** Additional context or metadata */ + metadata: z.record(z.unknown()).optional(), + + // ───────────────────────────────────────────────────────────────────────── + // CONTEXT - Request Information + // ───────────────────────────────────────────────────────────────────────── + + /** IP address */ + ipAddress: z.string().optional(), + + /** User agent */ + userAgent: z.string().optional(), + + /** Request ID */ + requestId: z.string().optional(), + + /** Session ID */ + sessionId: z.string().optional(), + + // ───────────────────────────────────────────────────────────────────────── + // COMPLIANCE + // ───────────────────────────────────────────────────────────────────────── + + /** Reason or justification */ + reason: z.string().optional(), +}); + +/** + * TypeScript type for a single audit log response. + */ +export type AuditLogResponseDto = z.infer; + +// ============================================================================ +// PAGINATED RESPONSE SCHEMA +// ============================================================================ + +/** + * Schema for paginated audit log responses. + * + * Contains the data array plus pagination metadata. + * This is the standard format for list endpoints. + */ +export const PaginatedAuditLogsResponseSchema = z.object({ + /** Array of audit logs for the current page */ + data: z.array(AuditLogResponseDtoSchema), + + /** Pagination metadata */ + pagination: z.object({ + /** Current page number (1-indexed) */ + page: z.number().int().min(1), + + /** Number of items per page */ + limit: z.number().int().min(1), + + /** Total number of items across all pages */ + total: z.number().int().min(0), + + /** Total number of pages */ + pages: z.number().int().min(0), + }), +}); + +/** + * TypeScript type for paginated audit log responses. + */ +export type PaginatedAuditLogsResponse = z.infer; + +// ============================================================================ +// OPERATION RESULT SCHEMAS +// ============================================================================ + +/** + * Schema for the result of creating an audit log. + * + * Returns the created audit log plus a success indicator. + */ +export const CreateAuditLogResultSchema = z.object({ + /** Whether the operation succeeded */ + success: z.boolean(), + + /** The created audit log */ + data: AuditLogResponseDtoSchema, + + /** Optional message */ + message: z.string().optional(), +}); + +/** + * TypeScript type for create audit log result. + */ +export type CreateAuditLogResult = z.infer; + +/** + * Schema for error responses. + * + * Standard error format for all audit kit operations. + */ +export const ErrorResponseSchema = z.object({ + /** Always false for errors */ + success: z.literal(false), + + /** Error message */ + error: z.string(), + + /** Error code (optional) */ + code: z.string().optional(), + + /** Additional error details */ + details: z.record(z.unknown()).optional(), + + /** Timestamp of the error */ + timestamp: z.string().datetime().optional(), +}); + +/** + * TypeScript type for error responses. + */ +export type ErrorResponse = z.infer; + +// ============================================================================ +// SUMMARY/STATISTICS SCHEMAS +// ============================================================================ + +/** + * Schema for audit log statistics/summary. + * + * Useful for dashboards and reporting. + */ +export const AuditLogStatsSchema = z.object({ + /** Total number of audit logs */ + total: z.number().int().min(0), + + /** Breakdown by action type */ + byAction: z.record(z.number().int().min(0)), + + /** Breakdown by actor type */ + byActorType: z.record(z.number().int().min(0)), + + /** Breakdown by resource type */ + byResourceType: z.record(z.number().int().min(0)), + + /** Date range covered */ + dateRange: z + .object({ + start: z.string().datetime(), + end: z.string().datetime(), + }) + .optional(), +}); + +/** + * TypeScript type for audit log statistics. + */ +export type AuditLogStats = z.infer; diff --git a/src/core/dtos/create-audit-log.dto.ts b/src/core/dtos/create-audit-log.dto.ts new file mode 100644 index 0000000..10f94ad --- /dev/null +++ b/src/core/dtos/create-audit-log.dto.ts @@ -0,0 +1,239 @@ +/** + * ============================================================================ + * CREATE AUDIT LOG DTO - INPUT VALIDATION + * ============================================================================ + * + * This file defines the Data Transfer Object (DTO) for creating audit log entries. + * It uses Zod for runtime validation and type inference. + * + * Purpose: + * - Validate input data when creating audit logs + * - Provide type-safe API for audit log creation + * - Auto-generate TypeScript types from Zod schemas + * + * Usage: + * ```typescript + * const result = CreateAuditLogDtoSchema.safeParse(inputData); + * if (result.success) { + * const validatedDto: CreateAuditLogDto = result.data; + * } + * ``` + * + * @packageDocumentation + */ + +import { z } from "zod"; + +import { ActorType, AuditActionType } from "../types"; + +// ============================================================================ +// NESTED SCHEMAS - Building Blocks +// ============================================================================ + +/** + * Schema for Actor data. + * + * Validates the entity that performed the action. + * - `id` and `type` are required + * - `name`, `email`, and `metadata` are optional + */ +export const ActorSchema = z.object({ + /** Unique identifier for the actor */ + id: z.string().min(1, "Actor ID is required"), + + /** Type of actor (user, system, or service) */ + type: z.nativeEnum(ActorType, { + errorMap: () => ({ message: "Invalid actor type" }), + }), + + /** Optional human-readable name */ + name: z.string().optional(), + + /** Optional email address */ + email: z.string().email("Invalid email format").optional(), + + /** Optional additional metadata */ + metadata: z.record(z.unknown()).optional(), +}); + +/** + * Schema for Resource data. + * + * Validates the entity that was affected by the action. + * - `type` and `id` are required + * - `label` and `metadata` are optional + */ +export const AuditResourceSchema = z.object({ + /** Type of resource (e.g., "user", "order", "invoice") */ + type: z.string().min(1, "Resource type is required"), + + /** Unique identifier for the resource */ + id: z.string().min(1, "Resource ID is required"), + + /** Optional human-readable label */ + label: z.string().optional(), + + /** Optional additional metadata */ + metadata: z.record(z.unknown()).optional(), +}); + +/** + * Schema for a single field change. + * + * Represents before/after values for a modified field. + */ +export const FieldChangeSchema = z.object({ + /** Previous value */ + from: z.unknown(), + + /** New value */ + to: z.unknown(), +}); + +/** + * Schema for ChangeSet (collection of field changes). + * + * Key = field name, Value = before/after values + */ +export const ChangeSetSchema = z.record(FieldChangeSchema); + +// ============================================================================ +// MAIN DTO SCHEMA +// ============================================================================ + +/** + * Zod schema for creating an audit log. + * + * This schema validates all input data for audit log creation. + * The `id` and `timestamp` fields are NOT included here - they are + * generated automatically by the service layer. + * + * Validation rules: + * - `actor`: Must be a valid Actor object + * - `action`: Must be a valid AuditActionType or non-empty string + * - `resource`: Must be a valid Resource object + * - `changes`, `metadata`, `ipAddress`, etc.: All optional + */ +export const CreateAuditLogDtoSchema = z.object({ + // ───────────────────────────────────────────────────────────────────────── + // REQUIRED FIELDS + // ───────────────────────────────────────────────────────────────────────── + + /** + * The entity that performed the action. + * Required - every audit log must have an actor. + */ + actor: ActorSchema, + + /** + * The type of action performed. + * Can be a standard AuditActionType enum value or a custom string. + */ + action: z.union([ + z.nativeEnum(AuditActionType, { + errorMap: () => ({ message: "Invalid action type" }), + }), + z.string().min(1, "Action cannot be empty"), + ]), + + /** + * The resource that was affected. + * Required - every audit log must reference a resource. + */ + resource: AuditResourceSchema, + + // ───────────────────────────────────────────────────────────────────────── + // OPTIONAL FIELDS + // ───────────────────────────────────────────────────────────────────────── + + /** Optional human-readable description of the action */ + actionDescription: z.string().optional(), + + /** + * Field-level changes (for UPDATE actions). + * Records before/after values for modified fields. + */ + changes: ChangeSetSchema.optional(), + + /** + * Additional context or metadata. + * Can contain any JSON-serializable data. + */ + metadata: z.record(z.unknown()).optional(), + + /** + * IP address from which the action was performed. + * Validated as IPv4 or IPv6. + */ + ipAddress: z + .string() + .ip({ version: "v4" }) + .or(z.string().ip({ version: "v6" })) + .optional(), + + /** User agent string (browser, API client, etc.) */ + userAgent: z.string().optional(), + + /** Request ID for distributed tracing */ + requestId: z.string().optional(), + + /** Session ID (if applicable) */ + sessionId: z.string().optional(), + + /** + * Human-readable reason or justification. + * May be required by compliance policies for sensitive operations. + */ + reason: z.string().optional(), +}); + +// ============================================================================ +// TYPESCRIPT TYPE INFERENCE +// ============================================================================ + +/** + * TypeScript type inferred from the Zod schema. + * + * This gives us compile-time type checking AND runtime validation. + * Use this type in service signatures, function parameters, etc. + */ +export type CreateAuditLogDto = z.infer; + +// ============================================================================ +// CONVENIENCE SCHEMAS - Partial Validation +// ============================================================================ + +/** + * Schema for "before" object in change tracking. + * + * Accepts any plain object - used when auto-detecting changes. + */ +export const BeforeStateSchema = z.record(z.unknown()); + +/** + * Schema for "after" object in change tracking. + * + * Accepts any plain object - used when auto-detecting changes. + */ +export const AfterStateSchema = z.record(z.unknown()); + +/** + * Schema for creating an audit log WITH automatic change detection. + * + * Instead of providing `changes` explicitly, you provide `before` and `after` + * objects and the service will calculate the diff. + */ +export const CreateAuditLogWithChangesSchema = CreateAuditLogDtoSchema.omit({ + changes: true, +}).extend({ + /** The entity state before the change */ + before: BeforeStateSchema.optional(), + + /** The entity state after the change */ + after: AfterStateSchema.optional(), +}); + +/** + * TypeScript type for audit log creation with auto change detection. + */ +export type CreateAuditLogWithChanges = z.infer; diff --git a/src/core/dtos/index.ts b/src/core/dtos/index.ts new file mode 100644 index 0000000..e47e2e6 --- /dev/null +++ b/src/core/dtos/index.ts @@ -0,0 +1,81 @@ +/** + * ============================================================================ + * DTOS INDEX - PUBLIC API FOR DATA TRANSFER OBJECTS + * ============================================================================ + * + * This file exports all DTOs (Data Transfer Objects) used for input/output + * validation and type safety. + * + * Purpose: + * - Centralized export point for all DTOs + * - Simplifies imports in consuming code + * - Clear public API boundary for the DTO layer + * + * Usage: + * ```typescript + * import { CreateAuditLogDto, QueryAuditLogsDto } from '@core/dtos'; + * ``` + * + * @packageDocumentation + */ + +// ============================================================================ +// CREATE AUDIT LOG DTO - Input for creating audit logs +// ============================================================================ + +export { + // Main DTO schema and type + CreateAuditLogDtoSchema, + type CreateAuditLogDto, + // Schema with automatic change detection + CreateAuditLogWithChangesSchema, + type CreateAuditLogWithChanges, + // Nested schemas (for reuse) + ActorSchema, + AuditResourceSchema, + FieldChangeSchema, + ChangeSetSchema, + BeforeStateSchema, + AfterStateSchema, +} from "./create-audit-log.dto"; + +// ============================================================================ +// QUERY AUDIT LOGS DTO - Input for searching/filtering audit logs +// ============================================================================ + +export { + // Main query DTO schema and type + QueryAuditLogsDtoSchema, + type QueryAuditLogsDto, + // Query DTO with date validation + QueryAuditLogsDtoWithDateValidationSchema, + type QueryAuditLogsDtoWithDateValidation, + // Constants + QUERY_CONSTANTS, +} from "./query-audit-logs.dto"; + +// ============================================================================ +// AUDIT LOG RESPONSE DTO - Output format for API responses +// ============================================================================ + +export { + // Single audit log response + AuditLogResponseDtoSchema, + type AuditLogResponseDto, + // Paginated list response + PaginatedAuditLogsResponseSchema, + type PaginatedAuditLogsResponse, + // Operation results + CreateAuditLogResultSchema, + type CreateAuditLogResult, + // Error responses + ErrorResponseSchema, + type ErrorResponse, + // Statistics/summary + AuditLogStatsSchema, + type AuditLogStats, + // Nested response schemas + ActorResponseSchema, + ResourceResponseSchema, + FieldChangeResponseSchema, +} from "./audit-log-response.dto"; diff --git a/src/core/dtos/query-audit-logs.dto.ts b/src/core/dtos/query-audit-logs.dto.ts new file mode 100644 index 0000000..e71ad37 --- /dev/null +++ b/src/core/dtos/query-audit-logs.dto.ts @@ -0,0 +1,309 @@ +/** + * ============================================================================ + * QUERY AUDIT LOGS DTO - SEARCH AND FILTER VALIDATION + * ============================================================================ + * + * This file defines the Data Transfer Object (DTO) for querying audit logs. + * It validates filter criteria, pagination parameters, and sorting options. + * + * Purpose: + * - Validate query parameters for audit log searches + * - Provide type-safe filtering and pagination + * - Support complex queries (date ranges, multiple filters, etc.) + * + * Usage: + * ```typescript + * const result = QueryAuditLogsDtoSchema.safeParse(queryParams); + * if (result.success) { + * const filters: QueryAuditLogsDto = result.data; + * } + * ``` + * + * @packageDocumentation + */ + +import { z } from "zod"; + +import { ActorType, AuditActionType } from "../types"; + +// ============================================================================ +// VALIDATION CONSTANTS +// ============================================================================ + +/** + * Maximum page size to prevent performance issues. + * Querying thousands of audit logs at once can strain the database. + */ +const MAX_PAGE_SIZE = 100; + +/** + * Default page size if not specified. + */ +const DEFAULT_PAGE_SIZE = 10; + +/** + * Allowed sort fields for audit logs. + * Prevents SQL injection and ensures we only sort on indexed fields. + */ +const ALLOWED_SORT_FIELDS = [ + "timestamp", + "action", + "actor.id", + "resource.type", + "resource.id", +] as const; + +// ============================================================================ +// MAIN QUERY DTO SCHEMA +// ============================================================================ + +/** + * Zod schema for querying audit logs. + * + * All fields are optional - you can search by any combination of filters. + * Pagination and sorting are also optional with sensible defaults. + * + * Validation rules: + * - Page must be >= 1 + * - Limit must be between 1 and MAX_PAGE_SIZE + * - Dates must be valid ISO strings or Date objects + * - Sort field must be in ALLOWED_SORT_FIELDS + */ +export const QueryAuditLogsDtoSchema = z.object({ + // ───────────────────────────────────────────────────────────────────────── + // PAGINATION + // ───────────────────────────────────────────────────────────────────────── + + /** + * Page number (1-indexed). + * Default: 1 + */ + page: z.number().int("Page must be an integer").min(1, "Page must be at least 1").default(1), + + /** + * Number of items per page. + * Default: 10, Max: 100 + */ + limit: z + .number() + .int("Limit must be an integer") + .min(1, "Limit must be at least 1") + .max(MAX_PAGE_SIZE, `Limit cannot exceed ${MAX_PAGE_SIZE}`) + .default(DEFAULT_PAGE_SIZE), + + /** + * Sort order. + * Format: "field" (ascending) or "-field" (descending) + * Example: "-timestamp" sorts by timestamp descending (newest first) + */ + sort: z + .string() + .refine( + (val) => { + // Strip leading "-" for descending sort + const field = val.startsWith("-") ? val.slice(1) : val; + return ALLOWED_SORT_FIELDS.includes(field as (typeof ALLOWED_SORT_FIELDS)[number]); + }, + { + message: `Sort field must be one of: ${ALLOWED_SORT_FIELDS.join(", ")}`, + }, + ) + .optional(), + + // ───────────────────────────────────────────────────────────────────────── + // ACTOR FILTERS + // ───────────────────────────────────────────────────────────────────────── + + /** + * Filter by actor ID. + * Example: Get all actions performed by user "user-123" + */ + actorId: z.string().min(1, "Actor ID cannot be empty").optional(), + + /** + * Filter by actor type. + * Example: Get all system-generated actions + */ + actorType: z + .nativeEnum(ActorType, { + errorMap: () => ({ message: "Invalid actor type" }), + }) + .optional(), + + /** + * Filter by actor email. + * Example: Get all actions by "admin@example.com" + */ + actorEmail: z.string().email("Invalid email format").optional(), + + // ───────────────────────────────────────────────────────────────────────── + // ACTION FILTERS + // ───────────────────────────────────────────────────────────────────────── + + /** + * Filter by action type. + * Can be a standard enum value or a custom action string. + * Example: Get all UPDATE actions + */ + action: z + .union([ + z.nativeEnum(AuditActionType, { + errorMap: () => ({ message: "Invalid action type" }), + }), + z.string().min(1, "Action cannot be empty"), + ]) + .optional(), + + /** + * Filter by multiple actions (OR condition). + * Example: Get all CREATE or UPDATE actions + */ + actions: z + .array(z.union([z.nativeEnum(AuditActionType), z.string().min(1, "Action cannot be empty")])) + .optional(), + + // ───────────────────────────────────────────────────────────────────────── + // RESOURCE FILTERS + // ───────────────────────────────────────────────────────────────────────── + + /** + * Filter by resource type. + * Example: Get all actions on "user" resources + */ + resourceType: z.string().min(1, "Resource type cannot be empty").optional(), + + /** + * Filter by specific resource ID. + * Example: Get all actions on user "user-456" + */ + resourceId: z.string().min(1, "Resource ID cannot be empty").optional(), + + // ───────────────────────────────────────────────────────────────────────── + // DATE RANGE FILTERS + // ───────────────────────────────────────────────────────────────────────── + + /** + * Filter by start date (inclusive). + * Returns audit logs from this date onwards. + * Accepts ISO string or Date object. + */ + startDate: z + .union([z.string().datetime(), z.date()]) + .transform((val) => (typeof val === "string" ? new Date(val) : val)) + .optional(), + + /** + * Filter by end date (inclusive). + * Returns audit logs up to this date. + * Accepts ISO string or Date object. + */ + endDate: z + .union([z.string().datetime(), z.date()]) + .transform((val) => (typeof val === "string" ? new Date(val) : val)) + .optional(), + + // ───────────────────────────────────────────────────────────────────────── + // CONTEXT FILTERS + // ───────────────────────────────────────────────────────────────────────── + + /** + * Filter by IP address. + * Example: Get all actions from a specific IP + */ + ipAddress: z + .string() + .ip({ version: "v4" }) + .or(z.string().ip({ version: "v6" })) + .optional(), + + /** + * Filter by request ID (for distributed tracing). + * Example: Get all audit logs for a specific request + */ + requestId: z.string().optional(), + + /** + * Filter by session ID. + * Example: Get all actions in a user session + */ + sessionId: z.string().optional(), + + // ───────────────────────────────────────────────────────────────────────── + // FULL-TEXT SEARCH + // ───────────────────────────────────────────────────────────────────────── + + /** + * Free-text search across multiple fields. + * Searches in: action description, resource label, metadata, reason + * Example: "password reset" might find all password-related actions + */ + search: z + .string() + .min(1, "Search query cannot be empty") + .max(200, "Search query too long") + .optional(), + + // ───────────────────────────────────────────────────────────────────────── + // CUSTOM FILTERS + // ───────────────────────────────────────────────────────────────────────── + + /** + * Additional custom filters (database-specific). + * Allows extending the query with application-specific criteria. + */ + customFilters: z.record(z.unknown()).optional(), +}); + +// ============================================================================ +// TYPESCRIPT TYPE INFERENCE +// ============================================================================ + +/** + * TypeScript type inferred from the Zod schema. + * + * Use this type for function parameters, API endpoints, etc. + */ +export type QueryAuditLogsDto = z.infer; + +// ============================================================================ +// DATE RANGE VALIDATION +// ============================================================================ + +/** + * Custom refinement: Ensure startDate is before endDate. + * + * This extended schema adds cross-field validation. + */ +export const QueryAuditLogsDtoWithDateValidationSchema = QueryAuditLogsDtoSchema.refine( + (data) => { + // If both dates are provided, startDate must be <= endDate + if (data.startDate && data.endDate) { + return data.startDate <= data.endDate; + } + return true; // Valid if only one or neither date is provided + }, + { + message: "Start date must be before or equal to end date", + path: ["startDate"], // Error will be attached to startDate field + }, +); + +/** + * TypeScript type for query DTO with date validation. + */ +export type QueryAuditLogsDtoWithDateValidation = z.infer< + typeof QueryAuditLogsDtoWithDateValidationSchema +>; + +// ============================================================================ +// EXPORT CONSTANTS +// ============================================================================ + +/** + * Export validation constants for use in other modules. + */ +export const QUERY_CONSTANTS = { + MAX_PAGE_SIZE, + DEFAULT_PAGE_SIZE, + ALLOWED_SORT_FIELDS, +} as const; diff --git a/src/core/errors/audit-not-found.error.ts b/src/core/errors/audit-not-found.error.ts new file mode 100644 index 0000000..6fa0866 --- /dev/null +++ b/src/core/errors/audit-not-found.error.ts @@ -0,0 +1,142 @@ +/** + * ============================================================================ + * AUDIT NOT FOUND ERROR - DOMAIN ERROR + * ============================================================================ + * + * This file defines a custom error for when an audit log cannot be found. + * + * Purpose: + * - Typed error for missing audit logs + * - Better error messages than generic "not found" + * - Distinguishable from other errors in error handling + * - Can carry additional context (audit log ID, query filters) + * + * Usage in services: + * ```typescript + * const log = await repository.findById(id); + * if (!log) { + * throw new AuditNotFoundError(id); + * } + * ``` + * + * Usage in error handlers: + * ```typescript + * if (error instanceof AuditNotFoundError) { + * return res.status(404).json({ error: error.message }); + * } + * ``` + * + * @packageDocumentation + */ + +/** + * Error thrown when an audit log cannot be found. + * + * This is a domain-specific error that indicates: + * - The requested audit log ID doesn't exist + * - A query returned no results when at least one was expected + * - The audit log was deleted (if deletion is supported) + * + * HTTP Status: 404 Not Found + */ +export class AuditNotFoundError extends Error { + /** + * Error name for type identification. + * Always 'AuditNotFoundError'. + */ + public readonly name = "AuditNotFoundError"; + + /** + * The audit log ID that was not found. + * Useful for logging and debugging. + */ + public readonly auditLogId?: string; + + /** + * Additional context about what was being searched for. + * Could include query filters, resource IDs, etc. + */ + public readonly context?: Record; + + /** + * Creates a new AuditNotFoundError. + * + * @param auditLogId - The ID that was not found (optional) + * @param message - Custom error message (optional, has a default) + * @param context - Additional context (optional) + * + * @example Basic usage + * ```typescript + * throw new AuditNotFoundError('audit-123'); + * // Error: Audit log with ID "audit-123" was not found + * ``` + * + * @example With custom message + * ```typescript + * throw new AuditNotFoundError('audit-456', 'No such audit log exists'); + * // Error: No such audit log exists + * ``` + * + * @example With context + * ```typescript + * throw new AuditNotFoundError('audit-789', undefined, { + * resourceType: 'user', + * resourceId: 'user-123' + * }); + * // Error: Audit log with ID "audit-789" was not found + * // (context available in error.context) + * ``` + */ + constructor(auditLogId?: string, message?: string, context?: Record) { + // Generate default message if not provided + const defaultMessage = auditLogId + ? `Audit log with ID "${auditLogId}" was not found` + : "Audit log was not found"; + + // Call parent Error constructor + super(message || defaultMessage); + + // Store additional properties (only if defined) + if (auditLogId !== undefined) this.auditLogId = auditLogId; + if (context !== undefined) this.context = context; + + // Maintain proper stack trace in V8 engines (Chrome, Node.js) + if ("captureStackTrace" in Error) { + (Error as any).captureStackTrace(this, AuditNotFoundError); + } + + // Set prototype explicitly for proper instanceof checks + Object.setPrototypeOf(this, AuditNotFoundError.prototype); + } + + /** + * Converts the error to a JSON object. + * + * Useful for serialization in API responses or logging. + * + * @returns JSON representation of the error + * + * @example + * ```typescript + * const error = new AuditNotFoundError('audit-123', undefined, { + * query: { actorId: 'user-1' } + * }); + * + * console.log(error.toJSON()); + * // { + * // name: 'AuditNotFoundError', + * // message: 'Audit log with ID "audit-123" was not found', + * // auditLogId: 'audit-123', + * // context: { query: { actorId: 'user-1' } } + * // } + * ``` + */ + public toJSON(): Record { + return { + name: this.name, + message: this.message, + auditLogId: this.auditLogId, + context: this.context, + }; + } +} diff --git a/src/core/errors/index.ts b/src/core/errors/index.ts new file mode 100644 index 0000000..2cb8a85 --- /dev/null +++ b/src/core/errors/index.ts @@ -0,0 +1,55 @@ +/** + * ============================================================================ + * ERRORS INDEX - PUBLIC API FOR DOMAIN ERRORS + * ============================================================================ + * + * This file exports all custom domain errors used by AuditKit. + * These errors provide typed, semantic error handling for audit operations. + * + * Purpose: + * - Centralized export point for all domain errors + * - Simplifies imports in services and error handlers + * - Clear distinction between different error types + * - Better error messages and debugging + * + * Usage: + * ```typescript + * import { + * AuditNotFoundError, + * InvalidActorError, + * InvalidChangeSetError + * } from '@core/errors'; + * + * // Throw errors + * throw new AuditNotFoundError('audit-123'); + * + * // Catch errors + * try { + * await auditService.findById(id); + * } catch (error) { + * if (error instanceof AuditNotFoundError) { + * // Handle not found specifically + * } + * } + * ``` + * + * @packageDocumentation + */ + +// ============================================================================ +// AUDIT NOT FOUND ERROR - 404 scenarios +// ============================================================================ + +export { AuditNotFoundError } from "./audit-not-found.error"; + +// ============================================================================ +// INVALID ACTOR ERROR - Actor validation failures +// ============================================================================ + +export { InvalidActorError } from "./invalid-actor.error"; + +// ============================================================================ +// INVALID CHANGESET ERROR - Change tracking validation failures +// ============================================================================ + +export { InvalidChangeSetError } from "./invalid-changeset.error"; diff --git a/src/core/errors/invalid-actor.error.ts b/src/core/errors/invalid-actor.error.ts new file mode 100644 index 0000000..a116180 --- /dev/null +++ b/src/core/errors/invalid-actor.error.ts @@ -0,0 +1,207 @@ +/** + * ============================================================================ + * INVALID ACTOR ERROR - DOMAIN ERROR + * ============================================================================ + * + * This file defines a custom error for invalid or missing actor information. + * + * Purpose: + * - Typed error for actor validation failures + * - Clear error messages for missing/invalid actor data + * - Distinguishable from other validation errors + * - Can carry details about what was invalid + * + * Usage in services: + * ```typescript + * if (!actor.id) { + * throw new InvalidActorError('Actor ID is required'); + * } + * ``` + * + * Usage in error handlers: + * ```typescript + * if (error instanceof InvalidActorError) { + * return res.status(400).json({ error: error.message }); + * } + * ``` + * + * @packageDocumentation + */ + +/** + * Error thrown when actor information is invalid or incomplete. + * + * This is a domain-specific error that indicates: + * - Actor ID is missing or empty + * - Actor type is invalid (not USER, SYSTEM, or SERVICE) + * - Required actor fields are missing (e.g., email for user actors) + * - Actor format doesn't match expected structure + * + * HTTP Status: 400 Bad Request + */ +export class InvalidActorError extends Error { + /** + * Error name for type identification. + * Always 'InvalidActorError'. + */ + public readonly name = "InvalidActorError"; + + /** + * The invalid actor data that caused the error. + * Useful for debugging validation issues. + */ + public readonly actor?: unknown; + + /** + * Specific validation errors (field-level details). + * Maps field names to error messages. + * + * @example + * ```typescript + * { + * 'id': 'Actor ID is required', + * 'type': 'Must be one of: user, system, service' + * } + * ``` + */ + public readonly validationErrors?: Record; + + /** + * Creates a new InvalidActorError. + * + * @param message - Error message describing the validation failure + * @param actor - The invalid actor data (optional, for debugging) + * @param validationErrors - Field-level validation errors (optional) + * + * @example Basic usage + * ```typescript + * throw new InvalidActorError('Actor ID is required'); + * // Error: Actor ID is required + * ``` + * + * @example With actor data + * ```typescript + * const invalidActor = { type: 'invalid', name: 'Test' }; + * throw new InvalidActorError('Invalid actor type', invalidActor); + * // Error: Invalid actor type + * // (actor data available in error.actor) + * ``` + * + * @example With field-level errors + * ```typescript + * throw new InvalidActorError('Actor validation failed', actor, { + * 'id': 'ID cannot be empty', + * 'type': 'Must be one of: user, system, service', + * 'email': 'Invalid email format' + * }); + * // Error: Actor validation failed + * // (validation details available in error.validationErrors) + * ``` + */ + constructor(message: string, actor?: unknown, validationErrors?: Record) { + // Call parent Error constructor + super(message); + + // Store additional properties + this.actor = actor; + if (validationErrors !== undefined) this.validationErrors = validationErrors; + + // Maintain proper stack trace in V8 engines (Chrome, Node.js) + if ("captureStackTrace" in Error) { + (Error as any).captureStackTrace(this, InvalidActorError); + } + + // Set prototype explicitly for proper instanceof checks + Object.setPrototypeOf(this, InvalidActorError.prototype); + } + + /** + * Converts the error to a JSON object. + * + * Useful for serialization in API responses or logging. + * + * @returns JSON representation of the error + * + * @example + * ```typescript + * const error = new InvalidActorError( + * 'Actor validation failed', + * { id: '', type: 'user' }, + * { id: 'ID cannot be empty' } + * ); + * + * console.log(error.toJSON()); + * // { + * // name: 'InvalidActorError', + * // message: 'Actor validation failed', + * // actor: { id: '', type: 'user' }, + * // validationErrors: { id: 'ID cannot be empty' } + * // } + * ``` + */ + public toJSON(): Record { + return { + name: this.name, + message: this.message, + actor: this.actor, + validationErrors: this.validationErrors, + }; + } + + /** + * Creates an error for missing actor ID. + * + * Convenience factory method for the most common actor error. + * + * @returns InvalidActorError with appropriate message + * + * @example + * ```typescript + * throw InvalidActorError.missingId(); + * // Error: Actor ID is required and cannot be empty + * ``` + */ + public static missingId(): InvalidActorError { + return new InvalidActorError("Actor ID is required and cannot be empty"); + } + + /** + * Creates an error for invalid actor type. + * + * Convenience factory method for actor type validation. + * + * @param invalidType - The invalid type value + * @returns InvalidActorError with appropriate message + * + * @example + * ```typescript + * throw InvalidActorError.invalidType('admin'); + * // Error: Invalid actor type "admin". Must be one of: user, system, service + * ``` + */ + public static invalidType(invalidType: unknown): InvalidActorError { + return new InvalidActorError( + `Invalid actor type "${invalidType}". Must be one of: user, system, service`, + { type: invalidType }, + ); + } + + /** + * Creates an error for missing required fields. + * + * Convenience factory method for incomplete actor data. + * + * @param missingFields - Array of missing field names + * @returns InvalidActorError with appropriate message + * + * @example + * ```typescript + * throw InvalidActorError.missingFields(['email', 'name']); + * // Error: Actor is missing required fields: email, name + * ``` + */ + public static missingFields(missingFields: string[]): InvalidActorError { + const fieldList = missingFields.join(", "); + return new InvalidActorError(`Actor is missing required fields: ${fieldList}`); + } +} diff --git a/src/core/errors/invalid-changeset.error.ts b/src/core/errors/invalid-changeset.error.ts new file mode 100644 index 0000000..b9db00c --- /dev/null +++ b/src/core/errors/invalid-changeset.error.ts @@ -0,0 +1,279 @@ +/** + * ============================================================================ + * INVALID CHANGESET ERROR - DOMAIN ERROR + * ============================================================================ + * + * This file defines a custom error for invalid change tracking data. + * + * Purpose: + * - Typed error for changeset validation failures + * - Clear error messages for malformed change tracking + * - Distinguishable from other validation errors + * - Can carry details about what was invalid + * + * Usage in services: + * ```typescript + * if (!changes || Object.keys(changes).length === 0) { + * throw new InvalidChangeSetError('No changes detected'); + * } + * ``` + * + * Usage in error handlers: + * ```typescript + * if (error instanceof InvalidChangeSetError) { + * return res.status(400).json({ error: error.message }); + * } + * ``` + * + * @packageDocumentation + */ + +import type { ChangeSet } from "../types"; + +/** + * Error thrown when changeset data is invalid or malformed. + * + * This is a domain-specific error that indicates: + * - ChangeSet structure is invalid (missing 'from' or 'to' properties) + * - Before/after states are identical (no actual changes) + * - ChangeSet contains invalid field names or data types + * - Change detection failed for some reason + * + * HTTP Status: 400 Bad Request + */ +export class InvalidChangeSetError extends Error { + /** + * Error name for type identification. + * Always 'InvalidChangeSetError'. + */ + public readonly name = "InvalidChangeSetError"; + + /** + * The invalid changeset that caused the error. + * Useful for debugging validation issues. + */ + public readonly changeSet?: ChangeSet | unknown; + + /** + * The field name that has an invalid change (if specific field error). + */ + public readonly fieldName?: string; + + /** + * Additional context about the error. + * Could include before/after values, expected format, etc. + */ + public readonly context?: Record; + + /** + * Creates a new InvalidChangeSetError. + * + * @param message - Error message describing the validation failure + * @param changeSet - The invalid changeset (optional, for debugging) + * @param fieldName - Specific field with invalid change (optional) + * @param context - Additional context (optional) + * + * @example Basic usage + * ```typescript + * throw new InvalidChangeSetError('ChangeSet is required for UPDATE actions'); + * // Error: ChangeSet is required for UPDATE actions + * ``` + * + * @example With changeset data + * ```typescript + * const invalid = { email: { from: 'test@example.com' } }; // Missing 'to' + * throw new InvalidChangeSetError('Invalid change structure', invalid); + * // Error: Invalid change structure + * // (changeset available in error.changeSet) + * ``` + * + * @example With field-specific error + * ```typescript + * throw new InvalidChangeSetError( + * 'Password field cannot be tracked', + * changes, + * 'password' + * ); + * // Error: Password field cannot be tracked + * // (field name available in error.fieldName) + * ``` + * + * @example With context + * ```typescript + * throw new InvalidChangeSetError( + * 'Before and after states are identical', + * changes, + * undefined, + * { before: { name: 'John' }, after: { name: 'John' } } + * ); + * ``` + */ + constructor( + message: string, + changeSet?: ChangeSet | unknown, + fieldName?: string, + context?: Record, + ) { + // Call parent Error constructor + super(message); + + // Store additional properties + this.changeSet = changeSet; + if (fieldName !== undefined) this.fieldName = fieldName; + if (context !== undefined) this.context = context; + + // Maintain proper stack trace in V8 engines (Chrome, Node.js) + if ("captureStackTrace" in Error) { + (Error as any).captureStackTrace(this, InvalidChangeSetError); + } + + // Set prototype explicitly for proper instanceof checks + Object.setPrototypeOf(this, InvalidChangeSetError.prototype); + } + + /** + * Converts the error to a JSON object. + * + * Useful for serialization in API responses or logging. + * + * @returns JSON representation of the error + * + * @example + * ```typescript + * const error = new InvalidChangeSetError( + * 'Empty changeset', + * {}, + * undefined, + * { reason: 'No changes detected' } + * ); + * + * console.log(error.toJSON()); + * // { + * // name: 'InvalidChangeSetError', + * // message: 'Empty changeset', + * // changeSet: {}, + * // context: { reason: 'No changes detected' } + * // } + * ``` + */ + public toJSON(): Record { + return { + name: this.name, + message: this.message, + changeSet: this.changeSet, + fieldName: this.fieldName, + context: this.context, + }; + } + + /** + * Creates an error for empty changeset. + * + * Convenience factory method for when no changes are detected. + * + * @returns InvalidChangeSetError with appropriate message + * + * @example + * ```typescript + * throw InvalidChangeSetError.empty(); + * // Error: ChangeSet is empty. No changes detected between before and after states. + * ``` + */ + public static empty(): InvalidChangeSetError { + return new InvalidChangeSetError( + "ChangeSet is empty. No changes detected between before and after states.", + ); + } + + /** + * Creates an error for missing changeset on UPDATE. + * + * Convenience factory method for UPDATE actions without changes. + * + * @returns InvalidChangeSetError with appropriate message + * + * @example + * ```typescript + * throw InvalidChangeSetError.missingForUpdate(); + * // Error: ChangeSet is required for UPDATE actions. Provide either 'changes' or 'before/after' states. + * ``` + */ + public static missingForUpdate(): InvalidChangeSetError { + return new InvalidChangeSetError( + "ChangeSet is required for UPDATE actions. Provide either 'changes' or 'before/after' states.", + ); + } + + /** + * Creates an error for malformed field change. + * + * Convenience factory method for invalid field change structure. + * + * @param fieldName - The field with invalid structure + * @param reason - Why it's invalid + * @returns InvalidChangeSetError with appropriate message + * + * @example + * ```typescript + * throw InvalidChangeSetError.malformedField( + * 'email', + * 'Missing "to" property' + * ); + * // Error: Field "email" has invalid change structure: Missing "to" property + * ``` + */ + public static malformedField(fieldName: string, reason: string): InvalidChangeSetError { + return new InvalidChangeSetError( + `Field "${fieldName}" has invalid change structure: ${reason}`, + undefined, + fieldName, + ); + } + + /** + * Creates an error for identical before/after states. + * + * Convenience factory method for when nothing actually changed. + * + * @param before - The before state + * @param after - The after state + * @returns InvalidChangeSetError with appropriate message + * + * @example + * ```typescript + * const state = { name: 'John' }; + * throw InvalidChangeSetError.noChanges(state, state); + * // Error: Before and after states are identical. No changes to track. + * ``` + */ + public static noChanges(before: unknown, after: unknown): InvalidChangeSetError { + return new InvalidChangeSetError( + "Before and after states are identical. No changes to track.", + undefined, + undefined, + { before, after }, + ); + } + + /** + * Creates an error for forbidden field tracking. + * + * Convenience factory method for fields that should never be audited. + * + * @param fieldName - The forbidden field name + * @returns InvalidChangeSetError with appropriate message + * + * @example + * ```typescript + * throw InvalidChangeSetError.forbiddenField('password'); + * // Error: Field "password" cannot be tracked in audit logs for security reasons + * ``` + */ + public static forbiddenField(fieldName: string): InvalidChangeSetError { + return new InvalidChangeSetError( + `Field "${fieldName}" cannot be tracked in audit logs for security reasons`, + undefined, + fieldName, + ); + } +} diff --git a/src/core/index.ts b/src/core/index.ts index b365e66..4178df0 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -1,3 +1,142 @@ -// Public exports from core go here. -// Keep core framework-free (no Nest imports). -export {}; +/** + * ============================================================================ + * CORE INDEX - PUBLIC API FOR AUDITKIT CORE + * ============================================================================ + * + * This file is the main export point for the core layer of AuditKit. + * Everything exported here is framework-free and can be used in any + * JavaScript/TypeScript environment. + * + * Purpose: + * - Centralized export for all core functionality + * - Clear public API boundary + * - Framework-agnostic domain logic + * + * Architecture Rules: + * - MUST be framework-free (no NestJS, no external SDKs) + * - All exports should be types, interfaces, or pure functions + * - No infrastructure concerns (databases, HTTP, etc.) + * + * Usage: + * ```typescript + * import { + * AuditLog, + * AuditActionType, + * CreateAuditLogDto, + * IAuditLogRepository, + * AuditNotFoundError + * } from '@core'; + * ``` + * + * @packageDocumentation + */ + +// ============================================================================ +// DOMAIN TYPES - Entities, Enums, Value Objects +// ============================================================================ + +export { + // Enums - Constrained values + ActorType, + AuditActionType, + + // Value Objects - Complex data structures + type Actor, + type AuditResource, + type FieldChange, + type ChangeSet, + + // Main Entity - Audit Log + type AuditLog, + + // Query & Pagination Types + type PageOptions, + type PageResult, + type AuditLogFilters, + + // Type Guards - Runtime type checking + isAuditActionType, + isActorType, +} from "./types"; + +// ============================================================================ +// DTOs - Data Transfer Objects (Input/Output Validation) +// ============================================================================ + +export { + // Create Audit Log DTO + CreateAuditLogDtoSchema, + type CreateAuditLogDto, + CreateAuditLogWithChangesSchema, + type CreateAuditLogWithChanges, + ActorSchema, + AuditResourceSchema, + FieldChangeSchema, + ChangeSetSchema, + BeforeStateSchema, + AfterStateSchema, + + // Query Audit Logs DTO + QueryAuditLogsDtoSchema, + type QueryAuditLogsDto, + QueryAuditLogsDtoWithDateValidationSchema, + type QueryAuditLogsDtoWithDateValidation, + QUERY_CONSTANTS, + + // Response DTOs + AuditLogResponseDtoSchema, + type AuditLogResponseDto, + PaginatedAuditLogsResponseSchema, + type PaginatedAuditLogsResponse, + CreateAuditLogResultSchema, + type CreateAuditLogResult, + ErrorResponseSchema, + type ErrorResponse, + AuditLogStatsSchema, + type AuditLogStats, + ActorResponseSchema, + ResourceResponseSchema, + FieldChangeResponseSchema, +} from "./dtos"; + +// ============================================================================ +// PORTS - Interfaces for Infrastructure Adapters +// ============================================================================ + +export { + // Repository Port - Data persistence abstraction + type IAuditLogRepository, + + // Change Detector Port - Change tracking abstraction + type IChangeDetector, + type ChangeDetectionOptions, + type ComparatorFunction, + type MaskingFunction, + + // ID Generator Port - Unique ID generation abstraction + type IIdGenerator, + type IdGenerationOptions, + type IdGeneratorInfo, + + // Timestamp Provider Port - Date/time abstraction + type ITimestampProvider, + type TimestampOptions, + type TimestampFormat, + type TimezoneOption, + type TimestampProviderInfo, +} from "./ports"; + +// ============================================================================ +// ERRORS - Domain-Specific Errors +// ============================================================================ + +export { + // Audit not found error (404) + AuditNotFoundError, + + // Invalid actor error (400) + InvalidActorError, + + // Invalid changeset error (400) + InvalidChangeSetError, +} from "./errors"; diff --git a/src/core/ports/audit-repository.port.ts b/src/core/ports/audit-repository.port.ts new file mode 100644 index 0000000..727dedb --- /dev/null +++ b/src/core/ports/audit-repository.port.ts @@ -0,0 +1,287 @@ +/** + * ============================================================================ + * AUDIT REPOSITORY PORT - PERSISTENCE ABSTRACTION + * ============================================================================ + * + * This file defines the port (interface) for audit log persistence. + * It's a contract that any storage implementation must fulfill. + * + * Purpose: + * - Abstract away persistence details (database, file system, etc.) + * - Allow the core service to depend on an interface, not implementation + * - Enable swapping storage backends without changing business logic + * - Support testing with mock implementations + * + * Pattern: Ports & Adapters (Hexagonal Architecture) + * - This is a PORT (interface) + * - Concrete implementations are ADAPTERS (e.g., DatabaseKitAdapter) + * + * Architecture Rules: + * - This interface is in core/ - framework-free + * - Implementations go in infra/ - can use external dependencies + * - Core services depend ONLY on this port, never on concrete adapters + * + * @packageDocumentation + */ + +import type { AuditLog, AuditLogFilters, PageOptions, PageResult } from "../types"; + +// ESLint disable for interface method parameters (they're part of the contract, not actual code) +/* eslint-disable no-unused-vars */ + +// =========================================================================== +// MAIN REPOSITORY PORT +// ============================================================================ + +/** + * Port (interface) for audit log persistence operations. + * + * Defines all data access methods needed by the audit service. + * Any storage backend (MongoDB, PostgreSQL, file system, etc.) must + * implement this interface. + * + * Key Characteristics: + * - **Immutable**: No update() or delete() methods (audit logs never change) + * - **Append-only**: Only create() for writing + * - **Query-heavy**: Multiple read methods for different access patterns + * + * Implementation Examples: + * - MongoAuditLogRepository (uses DatabaseKit MongoDB adapter) + * - PostgresAuditLogRepository (uses DatabaseKit PostgreSQL adapter) + * - InMemoryAuditLogRepository (for testing) + * - FileAuditLogRepository (append-only JSON files) + */ +export interface IAuditLogRepository { + // ───────────────────────────────────────────────────────────────────────── + // WRITE OPERATIONS (Create Only - Immutable Audit Logs) + // ───────────────────────────────────────────────────────────────────────── + + /** + * Creates (persists) a new audit log entry. + * + * This is the ONLY write operation - audit logs are immutable once created. + * The implementation should: + * - Persist the audit log to storage + * - Return the complete audit log (with any DB-generated fields) + * - Ensure atomicity (all-or-nothing) + * + * @param log - The audit log to persist + * @returns The persisted audit log (may include DB-generated fields) + * @throws Error if persistence fails + * + * @example + * ```typescript + * const auditLog: AuditLog = { + * id: 'audit-123', + * actor: { id: 'user-1', type: ActorType.USER }, + * action: AuditActionType.UPDATE, + * resource: { type: 'user', id: 'user-456' }, + * timestamp: new Date(), + * }; + * const saved = await repository.create(auditLog); + * ``` + */ + create(_log: AuditLog): Promise; + + // ───────────────────────────────────────────────────────────────────────── + // READ OPERATIONS - Single Entity Retrieval + // ───────────────────────────────────────────────────────────────────────── + + /** + * Finds a single audit log by its unique identifier. + * + * @param id - The audit log ID + * @returns The audit log if found, null otherwise + * @throws Error if query execution fails + * + * @example + * ```typescript + * const log = await repository.findById('audit-123'); + * if (log) { + * console.log('Found:', log.action); + * } else { + * console.log('Not found'); + * } + * ``` + */ + findById(_id: string): Promise; + + // ───────────────────────────────────────────────────────────────────────── + // READ OPERATIONS - List/Collection Retrieval + // ───────────────────────────────────────────────────────────────────────── + + /** + * Finds all audit logs for a specific actor. + * + * Returns all actions performed by the given actor (user, system, service). + * Useful for: + * - User activity reports + * - Security investigations + * - Compliance audits + * + * @param actorId - The actor's unique identifier + * @param filters - Optional additional filters (date range, action type, etc.) + * @returns Array of audit logs (may be empty) + * @throws Error if query execution fails + * + * @example + * ```typescript + * // Get all actions by user-123 in the last 30 days + * const logs = await repository.findByActor('user-123', { + * startDate: new Date('2026-02-01'), + * endDate: new Date('2026-03-01'), + * }); + * ``` + */ + findByActor(_actorId: string, _filters?: Partial): Promise; + + /** + * Finds all audit logs for a specific resource. + * + * Returns the complete history of a resource (all actions performed on it). + * Useful for: + * - Entity audit trails + * - Change history tracking + * - Debugging data issues + * + * @param resourceType - The type of resource (e.g., "user", "order") + * @param resourceId - The resource's unique identifier + * @param filters - Optional additional filters + * @returns Array of audit logs (may be empty) + * @throws Error if query execution fails + * + * @example + * ```typescript + * // Get complete history of order-789 + * const history = await repository.findByResource('order', 'order-789'); + * console.log('Order was:', history.map(log => log.action)); + * // Output: ['CREATE', 'UPDATE', 'UPDATE', 'DELETE'] + * ``` + */ + findByResource( + _resourceType: string, + _resourceId: string, + _filters?: Partial, + ): Promise; + + /** + * Queries audit logs with complex filters and pagination. + * + * This is the most flexible query method - supports: + * - Multiple filter combinations + * - Pagination (page/limit) + * - Sorting + * - Date ranges + * - Full-text search (if supported by backend) + * + * @param filters - Filter criteria and pagination options + * @returns Paginated result with data and metadata + * @throws Error if query execution fails + * + * @example + * ```typescript + * // Get page 2 of UPDATE actions, 20 per page, sorted by newest first + * const result = await repository.query({ + * action: AuditActionType.UPDATE, + * page: 2, + * limit: 20, + * sort: '-timestamp', + * }); + * console.log(`Found ${result.total} total, showing ${result.data.length}`); + * ``` + */ + query(_filters: Partial & Partial): Promise>; + + // ───────────────────────────────────────────────────────────────────────── + // READ OPERATIONS - Aggregation/Statistics + // ───────────────────────────────────────────────────────────────────────── + + /** + * Counts audit logs matching the given filters. + * + * Useful for: + * - Dashboard statistics + * - Quota tracking + * - Performance monitoring (before running expensive queries) + * + * @param filters - Optional filter criteria + * @returns Number of matching audit logs + * @throws Error if query execution fails + * + * @example + * ```typescript + * // Count failed login attempts today + * const failedLogins = await repository.count({ + * action: 'LOGIN_FAILED', + * startDate: new Date(new Date().setHours(0, 0, 0, 0)), + * }); + * if (failedLogins > 100) { + * console.warn('Possible brute force attack!'); + * } + * ``` + */ + count(_filters?: Partial): Promise; + + /** + * Checks if any audit log exists matching the filters. + * + * More efficient than count() or query() when you only need to know + * "does at least one exist?" + * + * @param filters - Filter criteria + * @returns True if at least one audit log matches, false otherwise + * @throws Error if query execution fails + * + * @example + * ```typescript + * // Check if user ever accessed sensitive data + * const hasAccessed = await repository.exists({ + * actorId: 'user-123', + * action: AuditActionType.ACCESS, + * resourceType: 'sensitive_document', + * }); + * ``` + */ + exists(_filters: Partial): Promise; + + // ───────────────────────────────────────────────────────────────────────── + // OPTIONAL OPERATIONS - Advanced Features + // ───────────────────────────────────────────────────────────────────────── + + /** + * Deletes audit logs older than the specified date. + * + * ⚠️ IMPORTANT: This violates audit log immutability! + * Only use for: + * - Compliance-mandated data retention policies + * - Archival before deletion (move to cold storage) + * + * Many implementations should NOT implement this method. + * If implemented, should require special permissions. + * + * @param beforeDate - Delete logs older than this date + * @returns Number of audit logs deleted + * @throws Error if deletion fails or not supported + * + * @example + * ```typescript + * // Delete audit logs older than 7 years (GDPR retention) + * const sevenYearsAgo = new Date(); + * sevenYearsAgo.setFullYear(sevenYearsAgo.getFullYear() - 7); + * const deleted = await repository.deleteOlderThan?.(sevenYearsAgo); + * ``` + */ + deleteOlderThan?(_beforeDate: Date): Promise; + + /** + * Archives audit logs to long-term storage. + * + * Moves audit logs to cheaper/slower storage (e.g., AWS Glacier, tape). + * The logs remain queryable but with higher latency. + * + * @param beforeDate - Archive logs older than this date + * @returns Number of audit logs archived + * @throws Error if archival fails or not supported + */ + archiveOlderThan?(_beforeDate: Date): Promise; +} diff --git a/src/core/ports/change-detector.port.ts b/src/core/ports/change-detector.port.ts new file mode 100644 index 0000000..8756bc8 --- /dev/null +++ b/src/core/ports/change-detector.port.ts @@ -0,0 +1,274 @@ +/** + * ============================================================================ + * CHANGE DETECTOR PORT - CHANGE TRACKING ABSTRACTION + * ============================================================================ + * + * This file defines the port (interface) for detecting changes between + * before/after states of entities. + * + * Purpose: + * - Automatically calculate what fields changed during an UPDATE operation + * - Abstract away the change detection algorithm + * - Support different strategies (deep diff, shallow diff, custom comparators) + * - Enable masking sensitive fields in change detection + * + * Pattern: Ports & Adapters (Hexagonal Architecture) + * - This is a PORT (interface) + * - Concrete implementations are ADAPTERS (e.g., DeepDiffChangeDetector) + * + * Architecture Rules: + * - This interface is in core/ - framework-free + * - Implementations go in infra/ - can use external libraries + * + * @packageDocumentation + */ + +import type { ChangeSet } from "../types"; + +// ESLint disable for interface method parameters (they're part of the contract, not actual code) +/* eslint-disable no-unused-vars */ + +// =========================================================================== +// CHANGE DETECTION OPTIONS +// ============================================================================ + +/** + * Configuration options for change detection. + * + * Allows customizing how changes are detected and reported. + */ +export interface ChangeDetectionOptions { + /** + * Fields to exclude from change detection. + * Useful for technical fields that change automatically. + * + * @example ['updatedAt', 'version', '__v'] + */ + excludeFields?: string[]; + + /** + * Fields to mask (hide the actual values). + * For sensitive fields like passwords, credit cards, etc. + * + * @example ['password', 'ssn', 'creditCard'] + */ + maskFields?: string[]; + + /** + * Strategy for masking field values. + * - 'full': Replace with '***' (default) + * - 'partial': Show first/last characters (e.g., '****1234') + * - 'hash': Show hash of value + */ + maskStrategy?: "full" | "partial" | "hash"; + + /** + * Maximum depth for nested object comparison. + * Prevents infinite recursion and limits complexity. + * + * @default 10 + */ + maxDepth?: number; + + /** + * Whether to include unchanged fields in the result. + * If true, all fields are included with from === to. + * If false (default), only changed fields are returned. + * + * @default false + */ + includeUnchanged?: boolean; + + /** + * Custom comparator functions for specific field types. + * Allows defining how to compare non-primitive values. + * + * @example + * ```typescript + * { + * 'dates': (a, b) => a.getTime() === b.getTime(), + * 'arrays': (a, b) => JSON.stringify(a) === JSON.stringify(b) + * } + * ``` + */ + customComparators?: Record boolean>; +} + +// ============================================================================ +// MAIN CHANGE DETECTOR PORT +// ============================================================================ + +/** + * Port (interface) for detecting changes between object states. + * + * Implementations must provide algorithms to: + * - Compare two objects (before/after) + * - Identify which fields changed + * - Capture the old and new values + * - Handle nested objects and arrays + * - Apply masking for sensitive fields + * + * Implementation Examples: + * - DeepDiffChangeDetector (uses deep-diff library) + * - ShallowChangeDetector (only top-level properties) + * - CustomChangeDetector (application-specific rules) + */ +export interface IChangeDetector { + /** + * Detects changes between two object states. + * + * Compares the `before` and `after` objects and returns a ChangeSet + * containing only the fields that changed (unless includeUnchanged is true). + * + * Algorithm should: + * 1. Recursively compare all properties (up to maxDepth) + * 2. Exclude specified fields + * 3. Mask sensitive fields + * 4. Handle special types (Dates, Arrays, etc.) with custom comparators + * 5. Return only changed fields (or all if includeUnchanged) + * + * @param before - The object state before the change + * @param after - The object state after the change + * @param options - Optional configuration for detection behavior + * @returns ChangeSet mapping field names to before/after values + * + * @example Basic usage + * ```typescript + * const before = { name: 'John', email: 'john@old.com', age: 30 }; + * const after = { name: 'John', email: 'john@new.com', age: 31 }; + * + * const changes = await detector.detectChanges(before, after); + * // Result: + * // { + * // email: { from: 'john@old.com', to: 'john@new.com' }, + * // age: { from: 30, to: 31 } + * // } + * ``` + * + * @example With field masking + * ```typescript + * const before = { username: 'user1', password: 'oldpass123' }; + * const after = { username: 'user1', password: 'newpass456' }; + * + * const changes = await detector.detectChanges(before, after, { + * maskFields: ['password'], + * maskStrategy: 'full' + * }); + * // Result: + * // { + * // password: { from: '***', to: '***' } + * // } + * ``` + * + * @example With field exclusion + * ```typescript + * const before = { name: 'John', updatedAt: new Date('2026-01-01') }; + * const after = { name: 'Johnny', updatedAt: new Date('2026-03-01') }; + * + * const changes = await detector.detectChanges(before, after, { + * excludeFields: ['updatedAt'] + * }); + * // Result: + * // { + * // name: { from: 'John', to: 'Johnny' } + * // } + * ``` + */ + detectChanges>( + _before: T, + _after: T, + _options?: ChangeDetectionOptions, + ): Promise | ChangeSet; + + /** + * Detects if two values are different. + * + * Helper method for comparing individual values. + * Uses the same comparison logic as detectChanges() but for single values. + * + * @param before - The value before the change + * @param after - The value after the change + * @param fieldName - Optional field name (for custom comparators) + * @returns True if values are different, false if the same + * + * @example + * ```typescript + * const changed = detector.hasChanged('oldValue', 'newValue'); + * // true + * + * const notChanged = detector.hasChanged(123, 123); + * // false + * + * const dateChanged = detector.hasChanged( + * new Date('2026-01-01'), + * new Date('2026-01-02') + * ); + * // true + * ``` + */ + hasChanged(_before: unknown, _after: unknown, _fieldName?: string): boolean; + + /** + * Applies masking to a field value. + * + * Masks sensitive data according to the configured strategy. + * Useful when you need to mask values outside of change detection. + * + * @param value - The value to mask + * @param strategy - Masking strategy (default: 'full') + * @returns The masked value + * + * @example + * ```typescript + * detector.maskValue('password123', 'full'); + * // '***' + * + * detector.maskValue('4111111111111234', 'partial'); + * // '****-****-****-1234' + * + * detector.maskValue('sensitive', 'hash'); + * // 'a3f1d...8e2' (SHA-256 hash) + * ``` + */ + maskValue(_value: unknown, _strategy?: "full" | "partial" | "hash"): string; + + /** + * Formats a ChangeSet for human-readable output. + * + * Converts a ChangeSet into a formatted string suitable for logs, + * notifications, or UI display. + * + * @param changes - The ChangeSet to format + * @returns Human-readable summary of changes + * + * @example + * ```typescript + * const changes = { + * email: { from: 'old@example.com', to: 'new@example.com' }, + * status: { from: 'pending', to: 'active' } + * }; + * + * const summary = detector.formatChanges(changes); + * // "Changed: email (old@example.com → new@example.com), status (pending → active)" + * ``` + */ + formatChanges(_changes: ChangeSet): string; +} + +// ============================================================================ +// HELPER TYPES +// ============================================================================ + +/** + * Type for a custom comparator function. + * + * Takes two values and returns true if they are considered equal. + */ +export type ComparatorFunction = (_a: unknown, _b: unknown) => boolean; + +/** + * Type for a masking function. + * + * Takes a value and returns the masked version. + */ +export type MaskingFunction = (_value: unknown) => string; diff --git a/src/core/ports/id-generator.port.ts b/src/core/ports/id-generator.port.ts new file mode 100644 index 0000000..308ff57 --- /dev/null +++ b/src/core/ports/id-generator.port.ts @@ -0,0 +1,269 @@ +/** + * ============================================================================ + * ID GENERATOR PORT - UNIQUE IDENTIFIER ABSTRACTION + * ============================================================================ + * + * This file defines the port (interface) for generating unique identifiers + * for audit log entries. + * + * Purpose: + * - Abstract away ID generation strategy + * - Allow different ID formats (UUID, nanoid, snowflake, etc.) + * - Enable predictable IDs for testing (sequential, fixed) + * - Support database-specific ID requirements + * + * Pattern: Ports & Adapters (Hexagonal Architecture) + * - This is a PORT (interface) + * - Concrete implementations are ADAPTERS (e.g., NanoidGenerator, UUIDGenerator) + * + * Architecture Rules: + * - This interface is in core/ - framework-free + * - Implementations go in infra/ - can use external libraries + * + * @packageDocumentation + */ + +// ESLint disable for interface method parameters (they're part of the contract, not actual code) +/* eslint-disable no-unused-vars */ + +// =========================================================================== +// ID GENERATION OPTIONS +// ============================================================================ + +/** + * Configuration options for ID generation. + * + * Allows customizing the generated ID format and characteristics. + */ +export interface IdGenerationOptions { + /** + * Optional prefix to add to generated IDs. + * Useful for namespacing or identifying entity types. + * + * @example 'audit_', 'log_', 'evt_' + */ + prefix?: string; + + /** + * Optional suffix to add to generated IDs. + * Less common but can be useful for sharding or routing. + */ + suffix?: string; + + /** + * Desired length of the ID (excluding prefix/suffix). + * Not all generators support custom lengths. + * + * @example 21 (nanoid default), 36 (UUID with hyphens) + */ + length?: number; + + /** + * Character set for ID generation. + * Not all generators support custom alphabets. + * + * @example 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + */ + alphabet?: string; + + /** + * Additional metadata to include in ID generation. + * Some generators (e.g., snowflake) can encode metadata. + */ + metadata?: Record; +} + +// ============================================================================ +// MAIN ID GENERATOR PORT +// ============================================================================ + +/** + * Port (interface) for generating unique identifiers. + * + * Implementations must provide algorithms to: + * - Generate unique IDs (string format) + * - Ensure uniqueness (probability or guarantee) + * - Support configuration (prefix, length, etc.) + * - Be performant (can generate many IDs quickly) + * + * Characteristics of a good ID: + * - **Unique**: No collisions (or extremely low probability) + * - **Sortable**: Lexicographically sortable by creation time (optional) + * - **Compact**: Short enough to use as DB primary key + * - **URL-safe**: No special characters that need escaping + * - **Human-friendly**: Readable and easy to copy/paste (optional) + * + * Implementation Examples: + * - NanoidGenerator (uses nanoid library - short, URL-safe, random) + * - UUIDv4Generator (uses crypto.randomUUID() - standard, 36 chars) + * - UUIDv7Generator (time-ordered UUIDs - sortable) + * - SequentialGenerator (testing only - predictable sequence) + * - SnowflakeGenerator (Twitter snowflake - 64-bit, time-ordered) + */ +export interface IIdGenerator { + /** + * Generates a new unique identifier. + * + * Algorithm should: + * 1. Generate a base ID (random, time-based, sequential, etc.) + * 2. Apply prefix if specified + * 3. Apply suffix if specified + * 4. Ensure result is unique (probabilistically or guaranteed) + * 5. Return as string + * + * @param options - Optional configuration for ID generation + * @returns A unique identifier as a string + * + * @example Basic usage + * ```typescript + * const id = generator.generate(); + * // 'V1StGXR8_Z5jdHi6B-myT' (nanoid) + * // or + * // '550e8400-e29b-41d4-a716-446655440000' (UUID) + * ``` + * + * @example With prefix + * ```typescript + * const id = generator.generate({ prefix: 'audit_' }); + * // 'audit_V1StGXR8_Z5jdHi6B-myT' + * ``` + * + * @example With custom length (if supported) + * ```typescript + * const id = generator.generate({ length: 10 }); + * // 'V1StGXR8_Z' (shorter) + * ``` + */ + generate(_options?: IdGenerationOptions): string; + + /** + * Generates multiple unique identifiers in one call. + * + * More efficient than calling generate() in a loop. + * Useful for bulk operations. + * + * @param count - Number of IDs to generate + * @param options - Optional configuration for ID generation + * @returns Array of unique identifiers + * + * @example + * ```typescript + * const ids = generator.generateBatch(100, { prefix: 'audit_' }); + * // ['audit_V1St...', 'audit_X2Ry...', ... (100 IDs)] + * ``` + */ + generateBatch(_count: number, _options?: IdGenerationOptions): string[]; + + /** + * Validates if a string is a valid ID format. + * + * Checks if the given string matches the expected ID format. + * Useful for: + * - Input validation + * - Security checks + * - Data integrity verification + * + * @param id - The string to validate + * @returns True if valid, false otherwise + * + * @example + * ```typescript + * generator.isValid('V1StGXR8_Z5jdHi6B-myT'); + * // true (valid nanoid) + * + * generator.isValid('invalid!@#'); + * // false (contains invalid characters) + * + * generator.isValid(''); + * // false (empty string) + * ``` + */ + isValid(_id: string): boolean; + + /** + * Extracts metadata from an ID if the generator encodes it. + * + * Some ID generators (e.g., snowflake, ULID) encode timestamp + * or other metadata in the ID. This method extracts that data. + * + * Returns null if the generator doesn't support metadata extraction + * or if the ID doesn't contain metadata. + * + * @param id - The ID to extract metadata from + * @returns Metadata object or null + * + * @example With snowflake IDs + * ```typescript + * const metadata = generator.extractMetadata('1234567890123456789'); + * // { timestamp: Date('2026-03-12T...'), workerId: 1, sequence: 0 } + * ``` + * + * @example With ULIDs (time-ordered IDs) + * ```typescript + * const metadata = generator.extractMetadata('01ARZ3NDEKTSV4RRFFQ69G5FAV'); + * // { timestamp: Date('2026-03-12T...') } + * ``` + * + * @example With random IDs (no metadata) + * ```typescript + * const metadata = generator.extractMetadata('V1StGXR8_Z5jdHi6B-myT'); + * // null (random IDs don't encode metadata) + * ``` + */ + extractMetadata?(_id: string): Record | null; + + /** + * Returns information about the generator implementation. + * + * Useful for debugging, monitoring, and documentation. + * + * @returns Generator information + * + * @example + * ```typescript + * const info = generator.getInfo(); + * // { + * // name: 'NanoidGenerator', + * // version: '5.0.0', + * // defaultLength: 21, + * // alphabet: 'A-Za-z0-9_-', + * // collisionProbability: '1% in ~10^15 IDs', + * // sortable: false, + * // encoding: null + * // } + * ``` + */ + getInfo(): IdGeneratorInfo; +} + +// ============================================================================ +// HELPER TYPES +// ============================================================================ + +/** + * Information about an ID generator implementation. + * + * Provides metadata about the generator's characteristics and capabilities. + */ +export interface IdGeneratorInfo { + /** Name of the generator */ + name: string; + + /** Version of the underlying library (if applicable) */ + version?: string; + + /** Default length of generated IDs */ + defaultLength: number; + + /** Character set used for IDs */ + alphabet: string; + + /** Description of collision probability */ + collisionProbability?: string; + + /** Whether IDs are sortable by creation time */ + sortable: boolean; + + /** Type of metadata encoded in IDs (if any) */ + encoding: "timestamp" | "sequence" | "custom" | null; +} diff --git a/src/core/ports/index.ts b/src/core/ports/index.ts new file mode 100644 index 0000000..a4f5a38 --- /dev/null +++ b/src/core/ports/index.ts @@ -0,0 +1,65 @@ +/** + * ============================================================================ + * PORTS INDEX - PUBLIC API FOR PORT INTERFACES + * ============================================================================ + * + * This file exports all port interfaces (abstractions) used by AuditKit core. + * Ports are contracts that infrastructure adapters must implement. + * + * Purpose: + * - Centralized export point for all ports + * - Simplifies imports in core services + * - Clear separation between interface (port) and implementation (adapter) + * + * Architecture Pattern: Ports & Adapters (Hexagonal Architecture) + * - **Ports**: Interfaces defined here (in core/) + * - **Adapters**: Implementations (in infra/) + * - **Core depends on ports**, not adapters + * - **Adapters depend on ports** and implement them + * + * Usage: + * ```typescript + * import { IAuditLogRepository, IChangeDetector } from '@core/ports'; + * ``` + * + * @packageDocumentation + */ + +// ============================================================================ +// REPOSITORY PORT - Data Persistence +// ============================================================================ + +export { type IAuditLogRepository } from "./audit-repository.port"; + +// ============================================================================ +// CHANGE DETECTOR PORT - Change Tracking +// ============================================================================ + +export { + type IChangeDetector, + type ChangeDetectionOptions, + type ComparatorFunction, + type MaskingFunction, +} from "./change-detector.port"; + +// ============================================================================ +// ID GENERATOR PORT - Unique Identifier Generation +// ============================================================================ + +export { + type IIdGenerator, + type IdGenerationOptions, + type IdGeneratorInfo, +} from "./id-generator.port"; + +// ============================================================================ +// TIMESTAMP PROVIDER PORT - Date/Time Operations +// ============================================================================ + +export { + type ITimestampProvider, + type TimestampOptions, + type TimestampFormat, + type TimezoneOption, + type TimestampProviderInfo, +} from "./timestamp-provider.port"; diff --git a/src/core/ports/timestamp-provider.port.ts b/src/core/ports/timestamp-provider.port.ts new file mode 100644 index 0000000..25daee8 --- /dev/null +++ b/src/core/ports/timestamp-provider.port.ts @@ -0,0 +1,388 @@ +/** + * ============================================================================ + * TIMESTAMP PROVIDER PORT - DATE/TIME ABSTRACTION + * ============================================================================ + * + * This file defines the port (interface) for providing timestamps + * in audit log entries. + * + * Purpose: + * - Abstract away date/time generation + * - Enable controlled time in tests (freeze time, time travel) + * - Support different time zones or UTC enforcement + * - Allow custom time sources (NTP servers, atomic clocks, etc.) + * + * Pattern: Ports & Adapters (Hexagonal Architecture) + * - This is a PORT (interface) + * - Concrete implementations are ADAPTERS (e.g., SystemTimestampProvider) + * + * Architecture Rules: + * - This interface is in core/ - framework-free + * - Implementations go in infra/ - can use external libraries + * + * Why abstract timestamps? + * - **Testing**: Mock time for deterministic tests + * - **Consistency**: Ensure all audit logs use same time source + * - **Compliance**: Some regulations require specific time sources + * - **Accuracy**: Use NTP or atomic clock for critical applications + * + * @packageDocumentation + */ + +// ESLint disable for interface method parameters (they're part of the contract, not actual code) +/* eslint-disable no-unused-vars */ + +// ============================================================================ +// TIMESTAMP FORMAT OPTIONS +// ============================================================================ + +/** + * Supported timestamp formats for serialization. + */ +export type TimestampFormat = + | "iso" // ISO 8601 string (e.g., '2026-03-12T10:30:00.000Z') + | "unix" // Unix timestamp in seconds (e.g., 1710241800) + | "unix-ms" // Unix timestamp in milliseconds (e.g., 1710241800000) + | "date"; // JavaScript Date object + +/** + * Timezone options for timestamp generation. + */ +export type TimezoneOption = "utc" | "local" | string; // string for IANA tz (e.g., 'America/New_York') + +// ============================================================================ +// TIMESTAMP PROVIDER OPTIONS +// ============================================================================ + +/** + * Configuration options for timestamp generation. + */ +export interface TimestampOptions { + /** + * Output format for the timestamp. + * Default: 'iso' + */ + format?: TimestampFormat; + + /** + * Timezone for timestamp generation. + * Default: 'utc' + * + * For audit logs, UTC is strongly recommended for consistency. + */ + timezone?: TimezoneOption; + + /** + * Precision for timestamps. + * - 'second': 1-second precision + * - 'millisecond': 1-millisecond precision (default) + * - 'microsecond': 1-microsecond precision (if supported) + */ + precision?: "second" | "millisecond" | "microsecond"; +} + +// ============================================================================ +// MAIN TIMESTAMP PROVIDER PORT +// ============================================================================ + +/** + * Port (interface) for providing timestamps. + * + * Implementations must provide methods to: + * - Get current timestamp + * - Format timestamps in different representations + * - Parse timestamps from strings + * - Support time manipulation (for testing) + * + * Implementation Examples: + * - SystemTimestampProvider (uses system clock - production default) + * - FixedTimestampProvider (returns fixed time - testing) + * - NTPTimestampProvider (syncs with NTP server - high accuracy) + * - OffsetTimestampProvider (adjusts system time by offset) + */ +export interface ITimestampProvider { + /** + * Returns the current timestamp. + * + * By default, returns a JavaScript Date object representing "now". + * Can be customized with options for format and timezone. + * + * @param options - Optional formatting and timezone options + * @returns Current timestamp in the requested format + * + * @example Basic usage (Date object) + * ```typescript + * const now = provider.now(); + * // Date('2026-03-12T10:30:00.000Z') + * ``` + * + * @example ISO string format + * ```typescript + * const now = provider.now({ format: 'iso' }); + * // '2026-03-12T10:30:00.000Z' + * ``` + * + * @example Unix timestamp + * ```typescript + * const now = provider.now({ format: 'unix' }); + * // 1710241800 + * ``` + * + * @example With timezone + * ```typescript + * const now = provider.now({ + * format: 'iso', + * timezone: 'America/New_York' + * }); + * // '2026-03-12T05:30:00.000-05:00' + * ``` + */ + now(_options?: TimestampOptions): Date | string | number; + + /** + * Converts a Date object to the specified format. + * + * Useful when you have a Date and need it in a different format. + * + * @param date - The date to format + * @param format - Desired output format + * @returns Formatted timestamp + * + * @example + * ```typescript + * const date = new Date('2026-03-12T10:30:00.000Z'); + * + * provider.format(date, 'iso'); + * // '2026-03-12T10:30:00.000Z' + * + * provider.format(date, 'unix'); + * // 1710241800 + * + * provider.format(date, 'unix-ms'); + * // 1710241800000 + * ``` + */ + format(date: Date, format: TimestampFormat): string | number | Date; + + /** + * Parses a timestamp string or number into a Date object. + * + * Handles multiple input formats and returns a normalized Date. + * + * @param timestamp - The timestamp to parse (ISO string, Unix, etc.) + * @returns Date object + * @throws Error if timestamp is invalid or unparseable + * + * @example + * ```typescript + * // Parse ISO string + * provider.parse('2026-03-12T10:30:00.000Z'); + * // Date('2026-03-12T10:30:00.000Z') + * + * // Parse Unix timestamp (seconds) + * provider.parse(1710241800); + * // Date('2026-03-12T10:30:00.000Z') + * + * // Parse Unix timestamp (milliseconds) + * provider.parse(1710241800000); + * // Date('2026-03-12T10:30:00.000Z') + * ``` + */ + parse(_timestamp: string | number): Date; + + /** + * Validates if a timestamp is well-formed and in the past. + * + * Useful for: + * - Input validation + * - Detecting clock skew + * - Rejecting future timestamps (possible attack) + * + * @param timestamp - The timestamp to validate + * @param allowFuture - Whether to allow future timestamps (default: false) + * @returns True if valid, false otherwise + * + * @example + * ```typescript + * // Valid past timestamp + * provider.isValid('2026-03-12T10:30:00.000Z'); + * // true + * + * // Future timestamp (rejected by default) + * provider.isValid('2027-03-12T10:30:00.000Z'); + * // false + * + * // Future timestamp (allowed) + * provider.isValid('2027-03-12T10:30:00.000Z', true); + * // true + * + * // Invalid format + * provider.isValid('not-a-date'); + * // false + * ``` + */ + isValid(_timestamp: string | number | Date, _allowFuture?: boolean): boolean; + + /** + * Returns the start of the day for the given date (00:00:00). + * + * Useful for date range queries in audit logs. + * + * @param date - The date (defaults to today) + * @param timezone - Timezone for calculation (default: UTC) + * @returns Date object representing start of day + * + * @example + * ```typescript + * const today = provider.startOfDay(); + * // Date('2026-03-12T00:00:00.000Z') + * + * const specific = provider.startOfDay(new Date('2026-03-15T14:30:00Z')); + * // Date('2026-03-15T00:00:00.000Z') + * ``` + */ + startOfDay(_date?: Date, _timezone?: TimezoneOption): Date; + + /** + * Returns the end of the day for the given date (23:59:59.999). + * + * Useful for date range queries in audit logs. + * + * @param date - The date (defaults to today) + * @param timezone - Timezone for calculation (default: UTC) + * @returns Date object representing end of day + * + * @example + * ```typescript + * const today = provider.endOfDay(); + * // Date('2026-03-12T23:59:59.999Z') + * ``` + */ + endOfDay(_date?: Date, _timezone?: TimezoneOption): Date; + + /** + * Calculates the difference between two timestamps. + * + * Returns the duration in various units. + * + * @param from - Start timestamp + * @param to - End timestamp + * @param unit - Unit for the result (default: 'milliseconds') + * @returns Duration in the specified unit + * + * @example + * ```typescript + * const start = new Date('2026-03-12T10:00:00Z'); + * const end = new Date('2026-03-12T10:30:00Z'); + * + * provider.diff(start, end, 'minutes'); + * // 30 + * + * provider.diff(start, end, 'seconds'); + * // 1800 + * + * provider.diff(start, end, 'milliseconds'); + * // 1800000 + * ``` + */ + diff( + _from: Date, + _to: Date, + _unit?: "milliseconds" | "seconds" | "minutes" | "hours" | "days", + ): number; + + // ───────────────────────────────────────────────────────────────────────── + // OPTIONAL METHODS - Testing & Advanced Features + // ───────────────────────────────────────────────────────────────────────── + + /** + * Freezes time at a specific timestamp (for testing). + * + * After calling this, all calls to now() return the frozen time. + * Only implemented in test-specific providers. + * + * @param timestamp - The time to freeze at + * + * @example + * ```typescript + * provider.freeze?.(new Date('2026-03-12T10:00:00Z')); + * provider.now(); // Always returns 2026-03-12T10:00:00Z + * provider.now(); // Still returns 2026-03-12T10:00:00Z + * ``` + */ + freeze?(_timestamp: Date): void; + + /** + * Advances frozen time by a duration (for testing). + * + * Only works if time is currently frozen. + * + * @param duration - Amount to advance (in milliseconds) + * + * @example + * ```typescript + * provider.freeze?.(new Date('2026-03-12T10:00:00Z')); + * provider.advance?.(60000); // Advance by 1 minute + * provider.now(); // Returns 2026-03-12T10:01:00Z + * ``` + */ + advance?(_duration: number): void; + + /** + * Unfreezes time, returning to real system time (for testing). + * + * @example + * ```typescript + * provider.freeze?.(new Date('2026-03-12T10:00:00Z')); + * provider.unfreeze?.(); + * provider.now(); // Returns actual current time + * ``` + */ + unfreeze?(): void; + + /** + * Returns information about the timestamp provider implementation. + * + * @returns Provider information + * + * @example + * ```typescript + * const info = provider.getInfo(); + * // { + * // name: 'SystemTimestampProvider', + * // source: 'system-clock', + * // timezone: 'UTC', + * // precision: 'millisecond', + * // frozen: false + * // } + * ``` + */ + getInfo(): TimestampProviderInfo; +} + +// ============================================================================ +// HELPER TYPES +// ============================================================================ + +/** + * Information about a timestamp provider implementation. + */ +export interface TimestampProviderInfo { + /** Name of the provider */ + name: string; + + /** Source of time (system-clock, ntp, fixed, etc.) */ + source: string; + + /** Default timezone */ + timezone: TimezoneOption; + + /** Precision of timestamps */ + precision: "second" | "millisecond" | "microsecond"; + + /** Whether time is currently frozen (for testing) */ + frozen: boolean; + + /** Current time offset from system clock (if any) */ + offset?: number; +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..da07c1a --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,365 @@ +/** + * ============================================================================ + * CORE DOMAIN TYPES - AUDITKIT + * ============================================================================ + * + * This file contains all core domain entities, enums, and value objects + * for the AuditKit package. These types are framework-free and represent + * the business domain of audit logging. + * + * Purpose: + * - Define the structure of an audit log entry + * - Define actor types (who performed the action) + * - Define action types (what was done) + * - Define resource representation (what was affected) + * - Define change tracking structure (before/after values) + * + * Architecture Rules: + * - NO framework imports (no NestJS, no external SDKs) + * - Pure TypeScript types and interfaces + * - Should be usable in any JavaScript/TypeScript environment + * + * @packageDocumentation + */ + +// ESLint disable for enum values (they're declarations, not usage) +/* eslint-disable no-unused-vars */ + +// ============================================================================ +// ENUMS - Constrained String Values +// ============================================================================ + +/** + * Types of actors that can perform auditable actions. + * + * - `user`: A human user (authenticated via JWT, session, etc.) + * - `system`: An automated system process (cron jobs, scheduled tasks) + * - `service`: Another microservice or external API + */ +export enum ActorType { + USER = "user", + SYSTEM = "system", + SERVICE = "service", +} + +/** + * Types of auditable actions in the system. + * + * Standard CRUD operations plus additional security/compliance actions: + * - `CREATE`: Entity creation + * - `UPDATE`: Entity modification + * - `DELETE`: Entity removal (hard or soft delete) + * - `ACCESS`: Reading/viewing sensitive data + * - `EXPORT`: Data export (CSV, PDF, etc.) + * - `IMPORT`: Bulk data import + * - `LOGIN`: User authentication event + * - `LOGOUT`: User session termination + * - `PERMISSION_CHANGE`: Authorization/role modification + * - `SETTINGS_CHANGE`: Configuration or settings update + * - `CUSTOM`: For application-specific actions + */ +export enum AuditActionType { + CREATE = "CREATE", + UPDATE = "UPDATE", + DELETE = "DELETE", + ACCESS = "ACCESS", + EXPORT = "EXPORT", + IMPORT = "IMPORT", + LOGIN = "LOGIN", + LOGOUT = "LOGOUT", + PERMISSION_CHANGE = "PERMISSION_CHANGE", + SETTINGS_CHANGE = "SETTINGS_CHANGE", + CUSTOM = "CUSTOM", +} + +/** + * Branded string type for custom (non-enum) audit actions. + * + * This preserves `AuditActionType` autocomplete/type-safety while still + * allowing consumers to opt in to custom action identifiers. + */ +export type CustomAuditAction = string & { + readonly __customAuditActionBrand: unique symbol; +}; + +// ============================================================================ +// VALUE OBJECTS - Embedded Domain Concepts +// ============================================================================ + +/** + * Represents the actor (who) that performed an auditable action. + * + * Contains identity and metadata about the entity that initiated the action. + * For users, this typically comes from JWT payload. For system/service actors, + * this is provided explicitly. + */ +export interface Actor { + /** Unique identifier for the actor (user ID, service name, etc.) */ + id: string; + + /** Type of actor (user, system, or service) */ + type: ActorType; + + /** Human-readable name or label */ + name?: string; + + /** Email address (for user actors) */ + email?: string; + + /** Additional metadata (roles, permissions, service version, etc.) */ + metadata?: Record; +} + +/** + * Represents the resource (what) that was affected by an action. + * + * This is a generic representation - the type identifies the kind of entity + * (e.g., "user", "order", "payment") and the id identifies the specific instance. + */ +export interface AuditResource { + /** Type/kind of resource (e.g., "user", "order", "invoice") */ + type: string; + + /** Unique identifier for the specific resource instance */ + id: string; + + /** Optional human-readable label (e.g., username, order number) */ + label?: string; + + /** Additional context about the resource */ + metadata?: Record; +} + +/** + * Represents a single field change (before → after). + * + * Used to track what changed during UPDATE operations. + * Both `from` and `to` are typed as `unknown` to support any data type. + */ +export interface FieldChange { + /** Previous value before the change */ + from: unknown; + + /** New value after the change */ + to: unknown; +} + +/** + * Collection of field changes for an entity. + * + * Key = field name, Value = before/after values + * + * Example: + * ```typescript + * { + * email: { from: "old@example.com", to: "new@example.com" }, + * status: { from: "pending", to: "active" } + * } + * ``` + */ +export type ChangeSet = Record; + +// ============================================================================ +// MAIN DOMAIN ENTITY - AuditLog +// ============================================================================ + +/** + * Core audit log entity representing a single auditable event. + * + * This is the main domain model. Every auditable action in the system + * results in one AuditLog entry. Audit logs are immutable once created. + * + * Properties are organized by concern: + * 1. Identity (id, timestamp) + * 2. Who did it (actor) + * 3. What was done (action) + * 4. What was affected (resource) + * 5. Details (changes, metadata) + * 6. Context (IP, user agent, reason) + */ +export interface AuditLog { + // ───────────────────────────────────────────────────────────────────────── + // IDENTITY + // ───────────────────────────────────────────────────────────────────────── + + /** Unique identifier for this audit log entry */ + id: string; + + /** When the action occurred (ISO 8601 timestamp) */ + timestamp: Date; + + // ───────────────────────────────────────────────────────────────────────── + // WHO - Actor Information + // ───────────────────────────────────────────────────────────────────────── + + /** The entity that performed the action */ + actor: Actor; + + // ───────────────────────────────────────────────────────────────────────── + // WHAT - Action Information + // ───────────────────────────────────────────────────────────────────────── + + /** The type of action performed */ + action: AuditActionType | CustomAuditAction; // Allow custom actions while preserving enum type-safety + + /** Optional human-readable description of the action */ + actionDescription?: string; + + // ───────────────────────────────────────────────────────────────────────── + // WHAT WAS AFFECTED - Resource Information + // ───────────────────────────────────────────────────────────────────────── + + /** The resource that was affected by the action */ + resource: AuditResource; + + // ───────────────────────────────────────────────────────────────────────── + // DETAILS - Changes and Metadata + // ───────────────────────────────────────────────────────────────────────── + + /** + * Field-level changes (for UPDATE actions). + * Tracks before/after values for each modified field. + */ + changes?: ChangeSet; + + /** + * Additional context or metadata about the action. + * Can include things like: + * - Reason for change + * - Related entity IDs + * - Business context + * - Compliance tags + */ + metadata?: Record; + + // ───────────────────────────────────────────────────────────────────────── + // CONTEXT - Request Information + // ───────────────────────────────────────────────────────────────────────── + + /** IP address from which the action was performed */ + ipAddress?: string; + + /** User agent string (browser, API client, etc.) */ + userAgent?: string; + + /** Request ID for tracing (if available) */ + requestId?: string; + + /** Session ID (if applicable) */ + sessionId?: string; + + // ───────────────────────────────────────────────────────────────────────── + // COMPLIANCE - Justification + // ───────────────────────────────────────────────────────────────────────── + + /** + * Human-readable reason or justification for the action. + * Required for sensitive operations in some compliance scenarios. + */ + reason?: string; +} + +// ============================================================================ +// QUERY & PAGINATION TYPES +// ============================================================================ + +/** + * Options for paginated queries. + * + * Generic pagination structure that works with any database backend. + */ +export interface PageOptions { + /** Page number (1-indexed) */ + page?: number; + + /** Number of items per page */ + limit?: number; + + /** Sort order (e.g., "-timestamp" for descending by timestamp) */ + sort?: string; +} + +/** + * Result of a paginated query. + * + * Contains the data plus pagination metadata. + */ +export interface PageResult { + /** Array of items for the current page */ + data: T[]; + + /** Current page number */ + page: number; + + /** Items per page */ + limit: number; + + /** Total number of items across all pages */ + total: number; + + /** Total number of pages */ + pages: number; +} + +/** + * Filter options for querying audit logs. + * + * All filters are optional - can be combined for complex queries. + */ +export interface AuditLogFilters { + /** Filter by actor ID */ + actorId?: string; + + /** Filter by actor type */ + actorType?: ActorType; + + /** Filter by action type */ + action?: AuditActionType | string; + + /** Filter by resource type */ + resourceType?: string; + + /** Filter by resource ID */ + resourceId?: string; + + /** Filter by date range - start */ + startDate?: Date; + + /** Filter by date range - end */ + endDate?: Date; + + /** Filter by IP address */ + ipAddress?: string; + + /** Free-text search across multiple fields */ + search?: string; + + /** Additional custom filters (database-specific) */ + customFilters?: Record; +} + +// ============================================================================ +// TYPE GUARDS - Runtime Type Checking +// ============================================================================ + +/** + * Type guard to check if a string is a valid AuditActionType enum value. + * + * @param value - The value to check + * @returns True if value is a valid AuditActionType + */ +export function isAuditActionType(value: unknown): value is AuditActionType { + return ( + typeof value === "string" && Object.values(AuditActionType).includes(value as AuditActionType) + ); +} + +/** + * Type guard to check if a string is a valid ActorType enum value. + * + * @param value - The value to check + * @returns True if value is a valid ActorType + */ +export function isActorType(value: unknown): value is ActorType { + return typeof value === "string" && Object.values(ActorType).includes(value as ActorType); +}