Action history
This recipe records Product mutations in the same database transaction and exposes an authenticated, resource-authorized history endpoint. It reuses the Product and ProductRepository from the complete application, including its JWT, context, tenancy, and TypeORM configuration.
Configure the feature
import { SdCoreModule } from '@sdcorejs/nestjs';
SdCoreModule.forRoot({
actionHistory: {
resolveActor: (context) => {
const principal = context.user as {
email?: string;
displayName?: string;
} | undefined;
return {
userId: context.userId,
username: principal?.email,
fullName: principal?.displayName,
};
},
authorizeRead: ({ context, tenantCode, table }) =>
context.tenant === tenantCode &&
table === 'public.product' &&
context.permissions?.includes('product:history:read') === true,
redactFields: ['costPrice', 'supplier.apiToken'],
maxPageSize: 50,
retentionDays: 365,
},
});Merge these actionHistory options into the existing root call; do not register a second SdCoreModule. The standalone fragment isolates the options for readability.
public.product is an example TypeORM tablePath; use dataSource.getMetadata(Product).tablePath when building policy in your application so naming strategy/schema changes stay correct. Omitting authorizeRead denies all reads.
The repository constructor must include logHistory: true. The feature registers its recorder by default, and a scoped CREATE/UPDATE/DELETE snapshot uses scope copied from the persisted row.
Custom tenant property
If the entity scopes by organizationCode rather than tenantCode, map it explicitly:
resolveResourceTenant: ({ resourceScope }) =>
typeof resourceScope.organizationCode === 'string'
? resourceScope.organizationCode
: undefined,This fragment belongs in the actionHistory options. Missing or conflicting tenant attribution fails before the resource mutation commits.
Mount the read controller
import { Module } from '@nestjs/common';
import { ActionHistoryController } from '@sdcorejs/nestjs/features';
@Module({
controllers: [ActionHistoryController],
})
export class HistoryHttpModule {}The controller is not auto-mounted. It provides:
GET /action-history/public.product/6e71382b-45e5-4f92-98f3-13bf48ae2f5e?pageNumber=0&pageSize=25
Authorization: Bearer <token>The response is HTTP 200 with:
{
"data": {
"items": [
{
"id": "history-uuid",
"tenantCode": "ACME",
"table": "public.product",
"tableId": "6e71382b-45e5-4f92-98f3-13bf48ae2f5e",
"type": "UPDATE",
"fromData": { "name": "Old", "costPrice": "[REDACTED]" },
"toData": { "name": "New", "costPrice": "[REDACTED]" },
"createdAt": "2026-07-20T00:00:00.000Z"
}
],
"total": 1
}
}history-uuid is illustrative; persisted IDs are UUIDs. Missing, malformed, unauthorized, and cross-tenant resources all return the same 404.
Manual domain event
import { Injectable } from '@nestjs/common';
import {
ActionHistoryService,
ActionHistoryType,
} from '@sdcorejs/nestjs/features';
@Injectable()
class ApprovalAudit {
constructor(private readonly history: ActionHistoryService) {}
record(orderId: string, before: unknown, after: unknown) {
return this.history.create({
table: 'public.order',
tableId: orderId,
type: ActionHistoryType.UPDATE,
fromData: before,
toData: after,
note: 'Approval state changed',
});
}
}Manual create() requires a trusted tenant context. Prefer repository recording when history must commit atomically with the domain row.
Retention and security
retentionDays does not delete rows. Implement a separately approved compliance job. Built-in and configured redaction runs before persistence, but it cannot identify every sensitive business field; keep snapshots minimal and read policy resource-specific.
