@flusys/nestjs-shared
v9.2.2
Published
Common shared utilities for Flusys NestJS applications
Maintainers
Readme
@flusys/nestjs-shared
Shared infrastructure for all FLUSYS NestJS packages — base CRUD classes, JWT guard, permission guard, response DTOs, hybrid cache, structured logging, and soft-delete entities.
Installation
npm install @flusys/nestjs-shared @flusys/nestjs-core
npm install typeorm @nestjs/passport @nestjs/jwt passport-jwt winston1. Module Registration
Register once in your root AppModule:
import { Module, MiddlewareConsumer, NestModule } from '@nestjs/common';
import { CacheModule, UtilsModule } from '@flusys/nestjs-shared/modules';
import { LoggerMiddleware } from '@flusys/nestjs-shared/middlewares';
import { GlobalExceptionFilter } from '@flusys/nestjs-shared/filters';
import { ResponseMetaInterceptor } from '@flusys/nestjs-shared/interceptors';
@Module({
imports: [
CacheModule.forRoot(true), // true = global; provides 'CACHE_INSTANCE' token
UtilsModule, // provides UtilsService
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(LoggerMiddleware).forRoutes('*');
}
}
// In bootstrap()
app.useGlobalFilters(new GlobalExceptionFilter());
app.useGlobalInterceptors(new ResponseMetaInterceptor());2. Entity Base Class
Every entity must extend Identity:
import { Entity, Column } from 'typeorm';
import { Identity } from '@flusys/nestjs-shared/entities';
@Entity('products')
export class Product extends Identity {
@Column({ length: 255 })
name: string;
// inherited: id (uuid), createdAt, updatedAt, deletedAt,
// createdById, updatedById, deletedById
}3. Generic CRUD — createApiController + ApiService
Service (REQUEST-scoped, DataSource Provider pattern)
The constructor takes the event entity name, the cache, UtilsService, the logger
name, whether reads are cacheable, the event module name, the DataSource provider
and the entity. All of them are required, so a service cannot silently skip one.
import { Inject, Injectable, Scope } from '@nestjs/common';
import { ApiService, HybridCache } from '@flusys/nestjs-shared/classes';
import { UtilsService } from '@flusys/nestjs-shared/modules';
@Injectable({ scope: Scope.REQUEST }) // required
export class ProductService extends ApiService<
CreateProductDto,
UpdateProductDto,
IProduct,
Product
> {
constructor(
@Inject('CACHE_INSTANCE') protected override cacheManager: HybridCache,
@Inject(UtilsService) protected override utilsService: UtilsService,
@Inject(ProductDataSourceProvider) dataSourceProvider: ProductDataSourceProvider,
) {
super(
PRODUCT_EVENT_ENTITIES.PRODUCT,
cacheManager,
utilsService,
ProductService.name,
true,
PRODUCT_EVENT_MODULE,
dataSourceProvider,
Product,
);
}
}A service whose table depends on runtime configuration passes the choice in the
same argument. Read the injected config through the constructor parameter, not
this - this is not available until super() returns:
constructor(
@Inject('CACHE_INSTANCE') protected override cacheManager: HybridCache,
@Inject(UtilsService) protected override utilsService: UtilsService,
@Inject(ProductConfigService) private readonly config: ProductConfigService,
@Inject(ProductDataSourceProvider) dataSourceProvider: ProductDataSourceProvider,
) {
super(
PRODUCT_EVENT_ENTITIES.PRODUCT,
cacheManager,
utilsService,
ProductService.name,
true,
PRODUCT_EVENT_MODULE,
dataSourceProvider,
config.isCompanyFeatureEnabled() ? ProductWithCompany : Product,
);
}Services are Scope.REQUEST, so the configuration is read once per request either
way. The repository itself is still created lazily, on first use.
Controller (factory function)
import { Controller, Inject } from '@nestjs/common';
import { createApiController } from '@flusys/nestjs-shared/classes';
@Controller('administration/products')
export class ProductController extends createApiController<
CreateProductDto,
UpdateProductDto,
ProductResponseDto,
IProduct,
ProductService
>(CreateProductDto, UpdateProductDto, ProductResponseDto, {
entityName: 'product',
security: {
insert: { level: 'permission', permissions: ['product.create'] },
insertMany: { level: 'permission', permissions: ['product.create'] },
getAll: { level: 'permission', permissions: ['product.read'] },
getById: { level: 'permission', permissions: ['product.read'] },
update: { level: 'permission', permissions: ['product.update'] },
updateMany: { level: 'permission', permissions: ['product.update'] },
delete: { level: 'permission', permissions: ['product.delete'] },
bulkUpsert: { level: 'permission', permissions: ['product.create'] },
getByIds: { level: 'permission', permissions: ['product.read'] },
getByFilter: { level: 'permission', permissions: ['product.read'] },
},
}) {
constructor(@Inject(ProductService) public override service: ProductService) {
super(service);
}
}Generated POST-only endpoints: /insert, /insert-many, /get-all, /get/:id,
/get-by-ids, /get-by-filter, /update, /update-many, /bulk-upsert, /delete.
Security levels: 'public' (no auth), 'jwt' (token only), 'permission' (token + action check).
An endpoint left out of a per-endpoint security map is never public: insertMany / updateMany take
the security of insert / update, getByIds / getByFilter that of getById (else getAll), and
bulkUpsert requires both insert and update (their permissions combined with AND). Anything still
unset - including a controller with no security option at all - requires a JWT. List an endpoint as
'public' explicitly to open it.
PermissionGuard fails closed on an empty requirement: @RequirePermission() with no codes, '', []
or { permissions: [] } is a 403 for every user (and a 401 without one), never a pass.
IdempotencyInterceptor (on insert / insert-many) replays the stored response for a repeated
X-Idempotency-Key only to the same tenant, user and endpoint, and releases the key when the request
fails, so a retry after an error is processed again rather than answered with 409.
Bodies of insert / insert-many / update / update-many / bulk-upsert are validated against the concrete Create/Update DTO by DtoBodyValidationPipe (@flusys/nestjs-shared/pipes, same DEFAULT_VALIDATION_OPTIONS as the global pipe: whitelist + forbidNonWhitelisted, no implicit conversion), because a generic @Body() dto: T reflects as Object and the global pipe skips it. Audit fields are never taken from the client: SetCreatedByOnBody / SetUpdateByOnBody / SetDeletedByOnBody drop any client-sent createdById / updatedById / deletedById and stamp the authenticated user's id (nothing without a user), and the pipe keeps only the audit key the endpoint's interceptor stamps. A slug the DTO does not declare is the Slug interceptor's and is kept.
Company scoping
applyCompanyFilter(query, { isCompanyFeatureEnabled, entityAlias }, user), buildCompanyWhereCondition(where, enabled, user)
and validateCompanyOwnership(entity, user, enabled, name) (@flusys/nestjs-shared/utils) keep a user to their own company
when the company feature is on. A logged-in user without a companyId matches nothing (and may not use another company's
row) - never every company's data. Passing no user at all (null / undefined) is a system call and is left unscoped.
Savepoints in a shared transaction
withSavepoint(queryRunner, fn) (@flusys/nestjs-shared/utils) runs fn inside a savepoint of an
already-open transaction: a failure rolls back only what fn did and is rethrown, so the caller can
swallow it and keep using the transaction. Use it for any step that may fail without failing the whole
unit of work - PostgreSQL aborts the entire transaction after one failed statement otherwise.
try {
await withSavepoint(queryRunner, () => service.insert(dto, user, { queryRunner }));
} catch {
// skipped; the transaction is still usable
}4. Guards and Decorators
import { JwtAuthGuard } from '@flusys/nestjs-shared/guards';
import { PermissionGuard } from '@flusys/nestjs-shared/guards';
import {
CurrentUser,
Public,
RequirePermission,
RequireAnyPermission,
} from '@flusys/nestjs-shared/decorators';
import { ILoggedUserInfo } from '@flusys/nestjs-shared/interfaces';
@UseGuards(JwtAuthGuard, PermissionGuard)
@RequirePermission('product.create')
@Post('insert')
async insert(@Body() dto: CreateProductDto, @CurrentUser() user: ILoggedUserInfo) { }
// Mark endpoint public (no JWT required)
@Public()
@Post('public-list')
async publicList() { }ILoggedUserInfo fields: id, email, name?, phone?, profilePictureId?, companyId?, branchId?.
matchesPermission(granted, required) (@flusys/nestjs-shared/utils) is the one wildcard matcher (*, prefix.*) behind PermissionGuard and SharedPermissionCacheService.matchPermission. For a check inside a handler, SharedPermissionCacheService.assertPermission(user, code) throws InsufficientPermissionsException (403) unless the user holds the code in their company/branch scope, and PermissionSystemUnavailableException when no cache is wired up - never re-implement that check. PERMISSION_RESOLVER / IPermissionResolver (@flusys/nestjs-shared/interfaces) is the provider interface for reading a user's codes in any company/branch scope, including one not loaded into the cache yet; nestjs-iam implements it, feature packages inject it @Optional().
evaluatePermissionLogic(logic, granted) evaluates an AND / OR ILogicNode tree with the same matcher and returns { passed, missingPermissions, operator } (a group without children fails closed); permissionLogicCodes(logic) lists the codes a tree names. PermissionGuard uses it for @RequirePermissionLogic, and SharedPermissionCacheService.assertPermissionLogic(user, logic) is the throwing form for code outside a guard (403 naming what is missing, 500 when no cache is wired up) - used by nestjs-entity-builder for flow URLs with a permission rule.
permissionLogicProblem(value) validates a stored rule ('invalid' | 'empty' | 'too_many' | null; at most MAX_PERMISSION_LOGIC_DEPTH (5) group levels and MAX_PERMISSION_LOGIC_CODES (50) codes) - use it for any DTO or config that accepts an ILogicNode. loadPermissionCodes(scope, { resolver, cache }) reads a user's codes from PERMISSION_RESOLVER when wired, else from a configured SharedPermissionCacheService, else throws PermissionSystemUnavailableException - never re-implement that fallback.
5. Response DTOs
All controllers return one of these shapes:
import {
SingleResponseDto, // insert, update, getById
ListResponseDto, // getAll, getByIds
BulkResponseDto, // insertMany, updateMany, bulkUpsert
MessageResponseDto, // delete
} from '@flusys/nestjs-shared/dtos';
// All shapes include: success, message, messageKey (for i18n), _meta (requestId, timestamp, duration)
// ListResponseDto / BulkResponseDto also include: meta (total, page, pageSize, count, totalPages)6. Exceptions
import {
NotFoundException,
ConflictException,
UnauthorizedException,
ForbiddenException,
ValidationException,
InternalServerException,
} from '@flusys/nestjs-shared/exceptions';
throw new NotFoundException({
message: 'User not found',
messageKey: USER_MESSAGES.NOT_FOUND,
});
throw new ConflictException({
message: `User already assigned to this ${typeName}. Please refresh and try again.`,
messageKey: USER_PERMISSION_MESSAGES.ALREADY_ASSIGNED,
messageVariables: { type: typeName },
});
throw new ValidationException([{ field: 'email', message: 'Invalid format' }]);All exceptions produce: { success: false, message, messageKey,messageVariables:{} code, _meta }.
Keyed diagnostics - when a service returns a list of problems (plan warnings, validation findings) instead of throwing, each item is a translatable reference, not text. IMessageRef (@flusys/nestjs-shared/interfaces) is { messageKey, messageVariables? }, built with messageRef(key, variables?) (@flusys/nestjs-shared/utils). A variable may itself be an IMessageRef (the frontend translates it first). IValidationError (errors[] of an exception) may carry messageKey/messageVariables instead of message.
7. Hybrid Cache
CacheModule.forRoot(isGlobal = true, memoryTtl = 60_000, memorySize = 5000) provides one HybridCache under CACHE_INSTANCE. The store is chosen by USE_CACHE_LABEL:
| USE_CACHE_LABEL | Data store | Stamps / claims | Use when |
| ------------------ | ------------------------------------------ | --------------------------- | ------------------------------------------ |
| memory (default) | in-process LRU | in-process | one instance only - nothing is shared |
| redis | Redis (REDIS_URL) | Redis (SET NX) | several instances |
| hybrid | in-process L1 + Redis L2 | Redis (SET NX) | several instances, hot immutable reads |
memory mode is single-instance only: invalidations, claims and one-time values live in the process, so a second instance never sees them. Run more than one instance with redis or hybrid.
API
| Method | Meaning |
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| get(key, { skipMemory? }) | Read; L1 first in hybrid unless skipMemory. Only undefined is a miss. |
| set(key, value, ttl?, { skipMemory? })| Write with ttl ms, else the default (memoryTtl) - on every layer, Redis included, so nothing lives forever. |
| del(key) | Delete from every layer (other instances' L1 copies are not reached). |
| getStamp(name) / bumpStamp(name) | Version stamp: created at random when absent, moved by bumpStamp atomically for every instance. |
| claim(name, ttl) / releaseClaim(name) | Atomic first-wins lock (Redis SET NX PX); true only for the first caller while it lives. |
| take(key, ttl) | Atomic read-and-delete of a one-time value: exactly one caller across all instances receives it. |
Rules
- L1 values must be immutable for their key. In
hybridmode the in-process layer of other instances is never invalidated, sodelor an overwrite does not reach them. A key whose value changes in place - sessions, revocation markers, one-time state, counters, flags - must use{ skipMemory: true }on bothgetandset. - One-time values use
take, neverget+del(two concurrent requests would both read the value). The claim behindtakeis per value, so a fresh value stored under the same key (a resent code) can be taken again. - Locks and de-duplication use
claimand release it when done; the ttl bounds how long a crashed instance holds it. - Invalidate by stamp, after commit. Embed
getStamp()in data keys andbumpStamp()to invalidate - never track key lists. Bump after the transaction commits: a bump inside it lets a concurrent reader re-cache old rows under the new stamp.
@Injectable()
export class MyService {
constructor(@Inject(CACHE_INSTANCE) private readonly cache: HybridCache) {}
async consumeResetToken(tokenHash: string) {
return this.cache.take<IResetState>(`reset:${tokenHash}`, 15 * 60_000);
}
async runOnce(jobId: string, work: () => Promise<void>) {
if (!(await this.cache.claim(`job:${jobId}`, 60_000))) return;
try {
await work();
} finally {
await this.cache.releaseClaim(`job:${jobId}`);
}
}
}ApiService caching
With isCacheable = true, findById and getAll are cached under [tenant_<id>_]entity_<name>_<id_x|all>_<stamp>_<sha256(query)>:
- The stamp (one per entity in
cacheDependencies(), joined) is read before the query, so a write that commits mid-read moves the stamp and the stale result lands under a key no later read asks for. - Cache failures on the read path (stamp, get, set) are logged and the read goes to the database.
insert/insertMany/update/updateMany/bulkUpsert/deletecallinvalidateCache()after commit - whether the service is cacheable or not, because another service may cache the same entity. A failed invalidation is logged, never fails the committed write.after*Operationhooks run before commit - never invalidate in them.
// A cached read that joins or embeds another entity must depend on it
protected override cacheDependencies(): string[] {
return [this.entityName, TASK_MANAGER_EVENT_ENTITIES.TASK];
}
// A service that writes another entity directly (repository, query runner, cascade) bumps it after commit
await this.utilsService.clearCache(OTHER_ENTITY_NAME, this.cacheManager, this.cacheTenant());UtilsService: getCacheStamp(cache, entityNames, tenantId?), getCacheKey(entityName, params, entityId, tenantId, stamp), clearCache(entityName, cache, tenantId?) (bumps the stamp; logs instead of throwing).
A cached result must depend only on the SQL it ran: anything else (current time, config, the user beyond what the query filters on, feature flags) belongs in the key or the read must not be cached.
Multi-tenant keys: one cache serves every tenant, so ApiService prefixes its keys and stamps with the current tenant (cacheTenant()), taken from a tenant-aware DataSource provider (isMultiTenant() / getCurrentTenantId(), which MultiTenantDataSourceService implements). A single database is never prefixed. Code that calls UtilsService itself must pass the same tenant: cacheTenantId(dataSourceProvider) from @flusys/nestjs-shared/utils, or this.cacheTenant() inside an ApiService.
Permission cache
A user's codes are cached under [tenant:<id>:]<format>:v:<user stamp> - the scope key from buildPermissionCacheKey (user, or company + branch with enableCompanyFeature) plus one stamp per user. resolvePermissionCacheKey(cache, options, config) gives the key (resolve it before reading grants from the database, write under that same key); invalidatePermissionCache(cache, userId, config) bumps the stamp and drops every company / branch scope of the user on every instance. The tenant prefix is added only when config.multiTenant is set - a single database ignores a tenant header.
SharedPermissionCacheService.getPermissions reads that key and, on a miss (first request, expiry, invalidation), falls back to PERMISSION_RESOLVER when it is provided (nestjs-iam); without one, a miss is "no permissions". setPermissions writes the current key; clearPermissions bumps the user stamp.
Idempotency
IdempotencyInterceptor keys X-Idempotency-Key by tenant, user and endpoint. A stored response is replayed (read with skipMemory); otherwise the request claims the key (409 system.duplicate.request while another request holds it), stores its response for 24 h with skipMemory, and releases the claim on success and on error so a failed request can be retried.
8. Hybrid Event Bus
The event counterpart of the hybrid cache: one wrapper, transport chosen by configuration.
| USE_EVENT_LABEL | Behaviour | Extra install |
| ----------------- | ------------------------------------------------------ | ---------------- |
| memory | In-process only (default) | none |
| rabbitmq | Topic exchange, cross-service | npm i amqplib |
| kafka | Single topic, cross-service | npm i kafkajs |
| hybrid | Both layers, duplicate delivery suppressed by event id | broker of choice |
Registering
EventBusModule is optional - register it only in an app that actually publishes or
consumes events.
import { EventBusModule } from '@flusys/nestjs-shared/modules';
@Module({
imports: [EventBusModule.forRoot({ serviceName: 'flusysnest' })],
})
export class AppModule {}Leave it out and nothing breaks: no package injects the bus as a hard dependency, and
publishDomainEvent - including the calls ApiService makes for you - finds no bus and
returns without doing anything. There is no placeholder registration to add just to keep
DI happy. forRoot({ enabled: false }) is for the other case: the app supports events but
this deployment has them switched off, and you want that stated at startup.
The one thing an unregistered app gives up is consumption - @OnDomainEvent methods are
bound at bootstrap by an explorer that ships inside the module, so without it handlers
never run and nothing warns about it.
Running against a real broker
Both brokers need their driver installed - npm i amqplib or npm i kafkajs. The
transports load it at runtime, so the package builds and boots without either.
- One delivery per service, not per replica. RabbitMQ binds a single queue named
<serviceName>.eventsand Kafka joins one consumer group named<serviceName>-events, so two replicas of the same service compete for each event and only one of them handles it. Give a service that must see every event on every replica its ownqueue/groupId. - A publisher consumes its own events. In
rabbitmqandkafkamode the local handlers run off the broker round trip, which is what makes a single code path work across services. Inhybridmode the in-process copy arrives first and the broker copy is dropped by event id, so a handler still runs exactly once. - RabbitMQ reconnects on its own. A dropped connection and a channel killed by a
protocol error both schedule one reconnect (
reconnectDelay, 5s by default), which re-binds every pattern onto the fresh channel. While it is down, publishes are dropped rather than queued - events must never fail the operation that produced them. - Kafka's consumer is built on the first bind, not at connect. A missing topic or an unavailable group coordinator therefore fails consumption alone and leaves the producer working, instead of taking the whole transport down to in-process delivery.
Publishing
Every service extending ApiService publishes automatically once its module enables
events — no per-service code. Turn it on in the module's own config:
StorageModule.forRoot({
config: {
...dbConfig,
events: { enabled: true },
},
});Every events option
| Option | Type | Default | Effect |
| ----------------- | --------------------------- | ------- | ---------------------------------------------------------------------- |
| enabled | boolean | false | Master switch for the module. Everything else is ignored when off. |
| actions | string[] | all | Allowlist of entity.action or bare action entries. Omit to publish all. |
| includePayload | boolean | true | Attach the saved rows as payload.data. Ids and metadata always ride. |
Two rules decide what actually reaches a consumer:
actionsis one allowlist covering CRUD and domain actions together. Listing only['logged-in']silences that module'screated/updated/deletedtoo - name everything you want kept, or omitactionsentirely.- An entry is either
entity.action, which covers that one entity, or a bare action, which covers every entity of the module. Sorole.updatedkeeps role updates and dropsaction.updated, whileupdatedkeeps every entity's updates. Entries are matched as routing patterns, sorole.*and*.purgedwork as well.
There is no separate entity filter: an enabled module publishes under every entity it has unless an entry narrows it, so a package gaining one is picked up with no config change.
Mixing the two forms narrows one entity without touching the others:
import { IAM_EVENT_ACTIONS } from '@flusys/nestjs-iam/config';
IAMModule.forRoot({
config: {
...dbConfig,
events: {
enabled: true,
actions: [
'created',
'deleted',
'role.updated',
`user_role.${IAM_EVENT_ACTIONS.PERMISSIONS_ASSIGNED}`,
],
},
},
});iam.role.updated publishes; iam.action.updated does not; both still publish created
and deleted.
Event names are always <module>.<entity>.<action>. CRUD lifecycle actions are
created, updated, deleted, restored, purged. Events are published after the
transaction commits and never fail the write that produced them.
The module's own events block is the single authority on whether it publishes: passing
an eventBus explicitly chooses which bus to publish on, it never overrides the switch,
so enabled: false silences the module everywhere. delete and restore carry ids only -
there is no row left to attach.
Payloads are walked before they leave the process: credential-like fields become
[REDACTED] (matched case and separator blind - apiKey, api_key, X-API-KEY,
private-key alike; @LogAction({ includeParams: true }) uses the same redaction), a relation that loops back becomes [CIRCULAR], anything past 8 levels deep
becomes [TRUNCATED], and a Buffer becomes [BINARY]. Saved TypeORM rows with
bidirectional relations are therefore safe to publish. The config blob on a
storageConfig or emailConfig row is redacted whole - S3, Azure, SFTP and SMTP
credentials live in it - so those rows publish their id, name and provider and nothing
else. Redaction is a package default, not something an application configures, so it
cannot be switched off by a config edit.
Registering each module
Every feature module takes the same events block in its own config, and every one of
them also has a forRootAsync variant that returns the identical config. The blocks
below spell out all options with that module's real entity and action names, so each is
copy-pasteable. Write the CRUD half as literals - created, updated, deleted,
restored, purged never change - and take every other action from its package's own
*_EVENT_ACTIONS constant, so a rename there fails the build instead of silently muting
the event. Each package exports its entity names the same way (STORAGE_EVENT_ENTITIES).
In an application, keep the per-module blocks in one map rather than scattering them
across registrations - flusysnest/src/config/modules.config.ts is the reference:
const domainEventsEnabled = envConfig.getBoolean('ENABLE_DOMAIN_EVENTS', false);
export const moduleEventsConfig: Record<FeatureModuleName, IModuleEventsConfig> = {
storage: {
enabled: domainEventsEnabled,
actions: [
'file_manager.created',
'file_manager.updated',
'file_manager.deleted',
'file_manager.restored',
'file_manager.purged',
`file_manager.${STORAGE_EVENT_ACTIONS.UPLOADED}`,
`file_manager.${STORAGE_EVENT_ACTIONS.REMOVED}`,
'folder.created',
// ...one line per entity and action the module can publish
],
},
// ...one entry per feature module
};
export function getBaseModuleConfig(moduleName: FeatureModuleName) {
return { ...getDbConfig(), events: moduleEventsConfig[moduleName] };
}
AuthModule.forRoot({ config: { ...authConfig, ...getBaseModuleConfig('auth') } });Spelling every entity.action pair out in full - CRUD lifecycle ones included, rather
than spreading a shared array or leaning on bare actions - is the same as omitting the
option, and makes the file a readable inventory of what each module publishes: delete one
line to stop publishing that one pair. The trade-off is that a package gaining an entity
or an action stays silent until it is listed, so keep a test asserting the list covers
each package's own *_EVENT_ENTITIES and *_EVENT_ACTIONS constants. One env flag still
arms every module, but each keeps its own block, so narrowing one module never touches
the others.
auth
import { AUTH_EVENT_ACTIONS } from '@flusys/nestjs-auth/config';
AuthModule.forRoot({
config: {
...dbConfig,
events: {
enabled: true,
actions: ['appuser.created', `session.${AUTH_EVENT_ACTIONS.LOGGED_IN}`],
includePayload: false,
},
},
});| Entity | Actions it can publish |
| ------------------------- | ------------------------------------------------------------------------------------------------------- |
| appuser | CRUD + registered, password-changed, password-reset-requested, password-reset, email-verified |
| company | CRUD |
| company_branch | CRUD |
| session | logged-in, logged-out, token-refreshed, company-switched |
| user_company_permission | permission-granted, permission-revoked |
iam
import { IAM_EVENT_ACTIONS } from '@flusys/nestjs-iam/config';
IAMModule.forRoot({
config: {
...dbConfig,
events: {
enabled: true,
actions: [
'role.created',
'role.deleted',
`user_role.${IAM_EVENT_ACTIONS.PERMISSIONS_ASSIGNED}`,
],
},
},
});| Entity | Actions it can publish |
| ----------------------------------------------------------- | ---------------------- |
| action, role | CRUD |
| user_action, role_action, company_action, user_role | permissions-assigned |
storage
import { STORAGE_EVENT_ACTIONS } from '@flusys/nestjs-storage/config';
StorageModule.forRoot({
config: {
...dbConfig,
events: {
enabled: true,
actions: ['folder.created', 'folder.deleted', `file_manager.${STORAGE_EVENT_ACTIONS.UPLOADED}`],
},
},
});| Entity | Actions it can publish |
| --------------- | ---------------------------- |
| file_manager | CRUD + uploaded, removed |
| folder | CRUD |
| storageConfig | CRUD |
Uploads and removals report provider keys in payload.ids, with location,
storageConfigId, totalSize and count in payload.metadata.
form-builder
import { FORM_BUILDER_EVENT_ACTIONS } from '@flusys/nestjs-form-builder/config';
FormBuilderModule.forRoot({
config: {
...dbConfig,
events: {
enabled: true,
actions: [`form_result.${FORM_BUILDER_EVENT_ACTIONS.SUBMITTED}`],
includePayload: false,
},
},
});| Entity | Actions it can publish |
| ------------- | --------------------------------- |
| form | CRUD |
| form_result | CRUD + submitted, draft-saved |
Submissions carry formId, isDraft and schemaVersion in payload.metadata, so an
audit consumer can work with includePayload: false and never see answer data.
import { EMAIL_EVENT_ACTIONS } from '@flusys/nestjs-email/config';
EmailModule.forRoot({
config: {
...dbConfig,
events: {
enabled: true,
actions: [
`email_message.${EMAIL_EVENT_ACTIONS.SENT}`,
`email_message.${EMAIL_EVENT_ACTIONS.FAILED}`,
],
includePayload: false,
},
},
});| Entity | Actions it can publish |
| --------------- | ---------------------- |
| emailConfig | CRUD |
| emailTemplate | CRUD |
| email_message | sent, failed |
event-manager
import { EVENT_MANAGER_EVENT_ACTIONS } from '@flusys/nestjs-event-manager/config';
EventManagerModule.forRoot({
config: {
...dbConfig,
events: {
enabled: true,
actions: [
'event.created',
`event.${EVENT_MANAGER_EVENT_ACTIONS.PARTICIPANT_STATUS_CHANGED}`,
],
},
},
});| Entity | Actions it can publish |
| ------- | ----------------------------------- |
| event | CRUD + participant-status-changed |
The participant event is keyed by the event id, with participantId,
participantUserId and status in payload.metadata.
notification
import { NOTIFICATION_EVENT_ACTIONS } from '@flusys/nestjs-notification/config';
NotificationModule.forRoot({
config: {
...dbConfig,
events: {
enabled: true,
actions: [
`notification.${NOTIFICATION_EVENT_ACTIONS.SENT}`,
`notification.${NOTIFICATION_EVENT_ACTIONS.READ}`,
],
includePayload: false,
},
},
});| Entity | Actions it can publish |
| -------------- | --------------------------------- |
| notification | CRUD + sent, read, all-read |
Subscribing to notification.** while the notification module itself reacts to other
modules' events is the usual way to fan a domain event out to users.
localization
import { LOCALIZATION_EVENT_ACTIONS } from '@flusys/nestjs-localization/config';
LocalizationModule.forRoot({
config: {
...dbConfig,
defaultLanguageCode: 'en',
events: {
enabled: true,
actions: [`translation.${LOCALIZATION_EVENT_ACTIONS.BULK_UPDATED}`],
includePayload: false,
},
},
});| Entity | Actions it can publish |
| ---------------- | ---------------------- |
| language | CRUD |
| translationKey | CRUD |
| translation | CRUD + bulk-updated |
bulk-updated carries every written id plus count in payload.metadata, and is not
published when there was nothing to write - useful for cache busting a translation CDN.
task-manager
import { TASK_MANAGER_EVENT_ACTIONS } from '@flusys/nestjs-task-manager/config';
TaskManagerModule.forRoot({
config: {
...dbConfig,
events: {
enabled: true,
actions: [`task.${TASK_MANAGER_EVENT_ACTIONS.MOVED}`, `task.${TASK_MANAGER_EVENT_ACTIONS.ASSIGNED}`],
},
},
});| Entity | Actions it can publish |
| ------------------------------------------ | -------------------------- |
| task | CRUD + moved, assigned |
| task_list | CRUD + reordered |
| task_board, task_label, task_comment | CRUD |
moved carries fromListId/toListId; assigned carries the assignee ids. Domain
events carry a payload.metadata descriptor that is attached even when includePayload
is off.
Publishing your own
import { publishDomainEvent } from '@flusys/nestjs-shared/classes';
await publishDomainEvent({
module: 'billing',
entity: 'invoice',
action: 'settled',
ids: [invoice.id],
metadata: { amount: invoice.total },
user,
});Inside a service that already extends ApiService, call
this.publishDomainAction(action, { ... }) - it fills in the module and entity for you.
Consuming
import { OnDomainEvent } from '@flusys/nestjs-shared/decorators';
import { DomainEvent } from '@flusys/nestjs-shared/interfaces';
@Injectable()
export class AuditConsumer {
@OnDomainEvent('storage.*.created', 'auth.appuser.deleted')
async handle(event: DomainEvent) {
// event.payload.ids / .data / .metadata, event.actor, event.action
}
}DomainEvent is the standard envelope shape - it covers both CRUD lifecycle and domain
actions, so one type fits every handler. * matches exactly one segment; ** (or #)
matches the rest. Handlers are discovered at bootstrap on any singleton provider or
controller registered with Nest - a REQUEST-scoped provider has no instance at that point,
so put consumers on singletons.
Database work in a handler. Every handler runs inside the request context of the event's
actor - tenant, company, user and request id restored from event.actor. When
MultiTenantDataSourceService has no HTTP request, it takes the tenant from that context, so a
handler reaches the right tenant database by resolving any request-scoped service through
ModuleRef - no tenant plumbing:
@Injectable()
export class InvoiceConsumer {
constructor(@Inject(ModuleRef) private readonly moduleRef: ModuleRef) {}
@OnDomainEvent('form-builder.form_result.submitted')
async handle(event: DomainEvent) {
const results = await this.moduleRef.resolve(FormResultService, ContextIdFactory.create(), { strict: false });
// ...any read or write here hits event.actor.tenantId's database
}
}Events a handler publishes carry the same actor, so a chain of handlers stays on one tenant.
The same fallback serves any service resolved through ModuleRef during an HTTP request.
Injecting EVENT_BUS_INSTANCE gives direct
publish / subscribe access - mark it @Optional(), so the service still resolves in an
app that never registers EventBusModule.
9. Branch Hierarchy
Branches nest through CompanyBranch.parentId (always inside one company). Two layers, so any package can use them without importing nestjs-auth:
Pure helpers (@flusys/nestjs-shared/utils) on a flat { id, parentId } list. They tolerate bad data: repeated ids keep their first entry, empty ids are ignored, a branch that is its own parent is a root, and parent cycles never loop.
| Function | Returns |
| --- | --- |
| collectBranchDescendantIds(nodes, ids, { includeSelf?, maxDepth? }) | every branch under ids, level by level; with includeSelf the given ids come first |
| collectBranchAncestorIds(nodes, id, { includeSelf?, maxDepth? }) | the parent chain, nearest parent first, stopping at a parent not in nodes |
| isBranchWithin(nodes, id, ancestorId) | id is ancestorId or anywhere under it |
| getBranchPath(nodes, id) | the nodes from the top-most known ancestor down to id |
| normalizeBranchIds(ids) | each id once, in order, without empty values |
| applyBranchFilter(query, { entityAlias, columnName?, includeUnassigned? }, ids) | adds alias.branchId IN (:...ids); includeUnassigned also keeps rows with no branch; no ids matches nothing (or only unassigned rows) |
maxDepth: 1 limits a walk to direct children or the direct parent.
BRANCH_HIERARCHY_RESOLVER (@flusys/nestjs-shared/interfaces), implemented by nestjs-auth's BranchHierarchyResolverAdapter when the company feature is on. Inject it @Optional():
| Method | Use it for |
| --- | --- |
| getDescendantIds(ids, { includeSelf? }) | structure only (every sub-branch, grants ignored) - e.g. admin screens; never to scope a user's data |
| getAncestorIds(id, { includeSelf? }) | breadcrumbs, inherited settings |
| isWithinBranch(id, ancestorId) | "does this record's branch sit under mine?" |
| getGrantedBranchIds(userId, companyId?) | the branches the user is granted - exactly what company select offers |
| getGrantedDescendantIds(userId, ids, { includeSelf? }) | the current branch sees its granted sub-branches' data; a branch dropdown filter that also matches granted sub-branches |
| hasBranchAccess(userId, branchId) | the user is granted that branch itself (a parent grant does not count) |
Results only hold existing, not soft-deleted branches (inactive ones are included), and empty, malformed or unknown ids match nothing. A soft-deleted branch cuts the chain, so what sits below it is no longer under its old ancestors. A user's granted branches are exactly what company select offers (COMPANY_ACCESS_RESOLVER, whose getUserAccess(userId) lists each as { id, companyId, parentId, name }). Grants are explicit: a grant on a parent never reaches a child that is not granted itself, in either direction - with Parent and Level 2 granted but not Level 1, getGrantedDescendantIds(userId, parent, { includeSelf: true }) is [parent, level2].
constructor(@Optional() @Inject(BRANCH_HIERARCHY_RESOLVER) private readonly branches?: IBranchHierarchyResolver) {}
async listForCurrentBranch(query: SelectQueryBuilder<Invoice>, user: ILoggedUserInfo) {
if (!this.branches || !user.branchId) return query;
const ids = await this.branches.getGrantedDescendantIds(user.id, user.branchId, { includeSelf: true });
return applyBranchFilter(query, { entityAlias: 'invoice', columnName: 'branch_id' }, ids);
}10. Expressions
A JSON expression tree any package can store and evaluate (@flusys/nestjs-shared/interfaces for the types, /utils for the engine). Pure functions, no Nest dependencies; every slot that takes a value takes another expression.
| type | Shape | Result |
| --- | --- | --- |
| literal | { value } | the value |
| field_ref | { ref } | ctx.resolveRef(ref) |
| arithmetic | { op, operands[] } | sum, subtract, multiply, divide, modulo, power, average, min, max; blank operands count as 0 |
| concat | { parts[], separator? } | joined text; with a separator, blank parts are skipped |
| template | { template } | "{{ ref }}" placeholders filled through resolveRef |
| function | { name, args[] } | see EXPRESSION_FUNCTIONS: text (incl. REGEX_EXTRACT / REGEX_REPLACE / REGEX_TEST), number, date (UTC) and COALESCE |
| if | { condition, whenTrue, whenFalse? } | condition is an AND / OR group of { left, comparison, right? }, groups nest |
import { evaluateExpression, assertValidExpression, isExpressionError } from '@flusys/nestjs-shared/utils';
assertValidExpression(expr, { isKnownRef: (ref) => fieldIds.has(ref) }); // on save
const value = evaluateExpression(expr, { resolveRef: (ref) => data[ref] });- Errors are
ExpressionError { code, variables }(plain, not HTTP) - mapcodeto your own message key; check withisExpressionError(), notinstanceof. - Limits (
EXPRESSION_LIMITS): depth 16, 500 nodes, 4000-char templates, 100k-char text results. - Regex runs on RE2 via
safeRegexProblem()/compileSafeRegex()(SAFE_REGEX_LIMITS): linear time, pattern length / counted-repeat caps. Nevernew RegExpon author input.
License
MIT © FLUSYS
