arch(platform-api): 🏗️ Introduce standardized cache invalidation and CRUD base classes to decouple cache logic and reduce boilerplate

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
autocommit 2026-05-17 07:41:15 -07:00
parent 302be6b890
commit 8a3cbad805
4 changed files with 213 additions and 0 deletions

View file

@ -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 {}

View file

@ -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<string>('REDIS_CACHE_INVALIDATE_CHANNEL', 'platform.cache.invalidate');
}
async publish(event: Omit<InvalidateEvent, 'ts'>): Promise<void> {
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),
);
}
}
}

View file

@ -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<TEntity> = DeepPartial<TEntity>,
TUpdateDto extends DeepPartial<TEntity> = DeepPartial<TEntity>,
> {
protected abstract readonly service: CrudServiceBase<TEntity>;
@Get()
async list(): Promise<TEntity[]> {
return this.service.findAll();
}
@Get(':id')
async get(@Param('id', new ParseUUIDPipe()) id: string): Promise<TEntity> {
return this.service.findOne(id);
}
@Post()
@HttpCode(HttpStatus.CREATED)
async create(@Body() dto: TCreateDto): Promise<TEntity> {
return this.service.create(dto);
}
@Patch(':id')
async update(
@Param('id', new ParseUUIDPipe()) id: string,
@Body() dto: TUpdateDto,
): Promise<TEntity> {
return this.service.update(id, dto);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@Param('id', new ParseUUIDPipe()) id: string): Promise<void> {
await this.service.remove(id);
}
}

View file

@ -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<TEntity extends ObjectLiteral & { id: string }> {
protected abstract readonly repo: Repository<TEntity>;
protected abstract readonly resourceKind: string;
protected abstract readonly cache: CacheInvalidateService;
async findAll(where?: FindOptionsWhere<TEntity>): Promise<TEntity[]> {
return this.repo.find(where ? { where } : {});
}
async findOne(id: string): Promise<TEntity> {
const entity = await this.repo.findOne({ where: { id } as FindOptionsWhere<TEntity> });
if (!entity) {
throw new NotFoundException(`${this.resourceKind} ${id} not found`);
}
return entity;
}
async create(input: DeepPartial<TEntity>): Promise<TEntity> {
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<TEntity>): Promise<TEntity> {
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<void> {
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<string, unknown>)['user_id'];
if (typeof userId === 'string') {
ctx.user_id = userId;
}
const orgId = (entity as Record<string, unknown>)['org_id'];
if (typeof orgId === 'string' || orgId === null) {
ctx.org_id = orgId;
}
return ctx;
}
private async emitInvalidate(entity: TEntity, op: InvalidateOp): Promise<void> {
await this.cache.publish({
kind: this.resourceKind,
id: entity.id,
op,
...this.resolveTenantContext(entity),
});
}
}