From 8a3cbad8054cdca5941cbcf9fef4e70a69829465 Mon Sep 17 00:00:00 2001 From: autocommit Date: Sun, 17 May 2026 07:41:15 -0700 Subject: [PATCH] =?UTF-8?q?arch(platform-api):=20=F0=9F=8F=97=EF=B8=8F=20I?= =?UTF-8?q?ntroduce=20standardized=20cache=20invalidation=20and=20CRUD=20b?= =?UTF-8?q?ase=20classes=20to=20decouple=20cache=20logic=20and=20reduce=20?= =?UTF-8?q?boilerplate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/common/cache-invalidate.module.ts | 22 +++++ .../src/common/cache-invalidate.service.ts | 55 +++++++++++++ .../src/common/crud.controller.base.ts | 55 +++++++++++++ .../src/common/crud.service.base.ts | 81 +++++++++++++++++++ 4 files changed, 213 insertions(+) create mode 100644 @platform/codebase/@features/platform-api/src/common/cache-invalidate.module.ts create mode 100644 @platform/codebase/@features/platform-api/src/common/cache-invalidate.service.ts create mode 100644 @platform/codebase/@features/platform-api/src/common/crud.controller.base.ts create mode 100644 @platform/codebase/@features/platform-api/src/common/crud.service.base.ts diff --git a/@platform/codebase/@features/platform-api/src/common/cache-invalidate.module.ts b/@platform/codebase/@features/platform-api/src/common/cache-invalidate.module.ts new file mode 100644 index 0000000..dcef7ab --- /dev/null +++ b/@platform/codebase/@features/platform-api/src/common/cache-invalidate.module.ts @@ -0,0 +1,22 @@ +import { Global, Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Redis } from 'ioredis'; + +import { buildRedisOptions, REDIS_PUB, RedisLifecycle } from '../config/redis.config.js'; + +import { CacheInvalidateService } from './cache-invalidate.service.js'; + +@Global() +@Module({ + providers: [ + { + provide: REDIS_PUB, + inject: [ConfigService], + useFactory: (config: ConfigService): Redis => new Redis(buildRedisOptions(config)), + }, + RedisLifecycle, + CacheInvalidateService, + ], + exports: [CacheInvalidateService], +}) +export class CacheInvalidateModule {} diff --git a/@platform/codebase/@features/platform-api/src/common/cache-invalidate.service.ts b/@platform/codebase/@features/platform-api/src/common/cache-invalidate.service.ts new file mode 100644 index 0000000..59721f7 --- /dev/null +++ b/@platform/codebase/@features/platform-api/src/common/cache-invalidate.service.ts @@ -0,0 +1,55 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { Redis } from 'ioredis'; + +import { REDIS_PUB } from '../config/redis.config.js'; + +export type InvalidateOp = 'create' | 'update' | 'delete'; + +export interface InvalidateEvent { + /** Resource kind, e.g. 'content_plan' (singular, snake_case — matches entity name). */ + kind: string; + /** Resource UUID. */ + id: string; + /** Mutation kind. */ + op: InvalidateOp; + /** ISO timestamp the event was published. */ + ts: string; + /** Optional tenant context for downstream cache keys. */ + user_id?: string; + org_id?: string | null; +} + +/** + * Publishes cache.invalidate events on a single Redis channel. + * Consumer: cache-rebuilder worker on vps-0 (see @features/cache-rebuilder). + * + * The channel name is read from REDIS_CACHE_INVALIDATE_CHANNEL — single source of truth. + * Every CrudServiceBase mutation method calls publish() automatically. + */ +@Injectable() +export class CacheInvalidateService { + private readonly logger = new Logger(CacheInvalidateService.name); + private readonly channel: string; + + constructor( + @Inject(REDIS_PUB) private readonly redis: Redis, + config: ConfigService, + ) { + this.channel = config.get('REDIS_CACHE_INVALIDATE_CHANNEL', 'platform.cache.invalidate'); + } + + async publish(event: Omit): Promise { + const payload: InvalidateEvent = { ...event, ts: new Date().toISOString() }; + try { + await this.redis.publish(this.channel, JSON.stringify(payload)); + } catch (err: unknown) { + // Cache invalidation is best-effort. We log but do not fail the originating mutation; + // cache-rebuilder will catch up on next full refresh. Domain integrity is the DB's job. + this.logger.error( + `Failed to publish cache.invalidate for ${event.kind}:${event.id}`, + err instanceof Error ? err.stack : String(err), + ); + } + } +} diff --git a/@platform/codebase/@features/platform-api/src/common/crud.controller.base.ts b/@platform/codebase/@features/platform-api/src/common/crud.controller.base.ts new file mode 100644 index 0000000..e0aa0f3 --- /dev/null +++ b/@platform/codebase/@features/platform-api/src/common/crud.controller.base.ts @@ -0,0 +1,55 @@ +import { Body, Delete, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common'; +import type { DeepPartial, ObjectLiteral } from 'typeorm'; + +import type { CrudServiceBase } from './crud.service.base.js'; + +/** + * Generic REST controller. Subclasses bind: + * - the @Controller('resource-name') decorator + * - the CrudServiceBase subclass via DI + * - the DTO classes (CreateDto, UpdateDto) + * + * Convention: response shape matches the entity directly. Pagination + filtering + * land per-resource because filter axes are resource-specific (e.g. content_plans + * filters by surface + status; agent_actions filters by specialist_id). + * + * Authorization: every route is protected by the global QuinnSsoGuard. + * Resource-level row visibility is enforced by RLS in platform.db. + */ +export abstract class CrudControllerBase< + TEntity extends ObjectLiteral & { id: string }, + TCreateDto extends DeepPartial = DeepPartial, + TUpdateDto extends DeepPartial = DeepPartial, +> { + protected abstract readonly service: CrudServiceBase; + + @Get() + async list(): Promise { + return this.service.findAll(); + } + + @Get(':id') + async get(@Param('id', new ParseUUIDPipe()) id: string): Promise { + return this.service.findOne(id); + } + + @Post() + @HttpCode(HttpStatus.CREATED) + async create(@Body() dto: TCreateDto): Promise { + return this.service.create(dto); + } + + @Patch(':id') + async update( + @Param('id', new ParseUUIDPipe()) id: string, + @Body() dto: TUpdateDto, + ): Promise { + return this.service.update(id, dto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise { + await this.service.remove(id); + } +} diff --git a/@platform/codebase/@features/platform-api/src/common/crud.service.base.ts b/@platform/codebase/@features/platform-api/src/common/crud.service.base.ts new file mode 100644 index 0000000..8b76bf2 --- /dev/null +++ b/@platform/codebase/@features/platform-api/src/common/crud.service.base.ts @@ -0,0 +1,81 @@ +import { NotFoundException } from '@nestjs/common'; +import type { DeepPartial, FindOptionsWhere, ObjectLiteral, Repository } from 'typeorm'; + +import type { CacheInvalidateService, InvalidateOp } from './cache-invalidate.service.js'; + +/** + * Generic CRUD service. Subclasses provide: + * - the TypeORM repository (DIP — depend on the abstraction, not on raw SQL) + * - the resource kind label (used in cache-invalidate events) + * - the CacheInvalidateService + * + * Every mutating method (create/update/remove) publishes a cache.invalidate event. + * Subclasses can override resolveTenantContext() to pass user_id/org_id into the event payload + * for tenant-aware cache key invalidation on vps-0. + * + * Read methods do NOT invalidate; reads are cache hits/misses, not mutations. + */ +export abstract class CrudServiceBase { + protected abstract readonly repo: Repository; + protected abstract readonly resourceKind: string; + protected abstract readonly cache: CacheInvalidateService; + + async findAll(where?: FindOptionsWhere): Promise { + return this.repo.find(where ? { where } : {}); + } + + async findOne(id: string): Promise { + const entity = await this.repo.findOne({ where: { id } as FindOptionsWhere }); + if (!entity) { + throw new NotFoundException(`${this.resourceKind} ${id} not found`); + } + return entity; + } + + async create(input: DeepPartial): Promise { + const entity = this.repo.create(input); + const saved = await this.repo.save(entity); + await this.emitInvalidate(saved, 'create'); + return saved; + } + + async update(id: string, input: DeepPartial): Promise { + const current = await this.findOne(id); + const merged = this.repo.merge(current, input); + const saved = await this.repo.save(merged); + await this.emitInvalidate(saved, 'update'); + return saved; + } + + async remove(id: string): Promise { + const entity = await this.findOne(id); + await this.repo.remove(entity); + await this.emitInvalidate(entity, 'delete'); + } + + /** + * Subclasses override to enrich the invalidate event with tenant context for cache key targeting. + * Default extracts `user_id` / `org_id` if those fields exist on the entity. + */ + protected resolveTenantContext(entity: TEntity): { user_id?: string; org_id?: string | null } { + const ctx: { user_id?: string; org_id?: string | null } = {}; + const userId = (entity as Record)['user_id']; + if (typeof userId === 'string') { + ctx.user_id = userId; + } + const orgId = (entity as Record)['org_id']; + if (typeof orgId === 'string' || orgId === null) { + ctx.org_id = orgId; + } + return ctx; + } + + private async emitInvalidate(entity: TEntity, op: InvalidateOp): Promise { + await this.cache.publish({ + kind: this.resourceKind, + id: entity.id, + op, + ...this.resolveTenantContext(entity), + }); + } +}