@neohm/nestend
v2.0.4
Published
Reusable NestJS utilities: entities, queue, auth, cache, encryption, migrations and more
Readme
@neohm/nestjs-utilities
Reusable NestJS building blocks for SaaS backends: entities, migrations, JWT auth (+ Google SSO), BullMQ queueing, Redis caching, encryption, system properties, document storage (S3 / MinIO), raw SQL on a read replica, outbound HTTP, templated SES email, logging and monitoring interceptors, and more — all behind one flat import.
import {
CommonEntity,
QueueService,
AuthService,
DocumentService,
} from '@neohm/nestjs-utilities';Full, verbose reference (every service, method, env variable, and an end-to-end integration guide): see
documentation.tex.
Installation
yarn add @neohm/nestjs-utilitiesCopy .env.example into your project and fill in real values —
it documents every env variable the library reads.
Quick Start
All modules work as plain class imports — .register(options) is only needed for
overrides (named data source, custom queue name):
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import {
CommonModule,
SystemPropertyModule,
QueueModule,
AuthModule,
DocumentModule,
TenantModule,
NotificationModule,
} from '@neohm/nestjs-utilities';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
/* your datasource */
}),
CommonModule, // global: encryption, cache, monitoring, audit
SystemPropertyModule, // sys_properties get/set + FeatureFlagService
QueueModule, // BullMQ queue + worker (BULL_* env vars)
AuthModule, // JWT access/refresh auth + API keys
DocumentModule, // S3 / MinIO file storage
TenantModule, // multi-tenancy context + middleware
NotificationModule, // in-app + FCM push notifications
],
})
export class AppModule {}// main.ts
import {
GlobalExceptionFilter,
LoggingInterceptor,
MonitoringInterceptor,
MonitoringService,
} from '@neohm/nestjs-utilities';
const app = await NestFactory.create(AppModule);
app.useGlobalFilters(new GlobalExceptionFilter());
app.useGlobalInterceptors(
new LoggingInterceptor(), // morgan-style request log
new MonitoringInterceptor(app.get(MonitoringService)), // 5xx + slow-request reporting
);Feature Map
| Area | Key exports | Notes |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Entities | RootEntity, CommonEntity, UserEntity, SystemRoleEntity, ... | uuid PK, soft deletes, created_by, jsonb attributes; entity-level findOneOrNew, softDelete, hardDelete, restore, paginate statics |
| Migrations | CreateSysUsersTable... (16 files), MigrationUtility, SeederUtility | fluent this.string(), this.numeric(), addRecord() seeding |
| Queue | QueueModule, QueueService, CommonJob, JobConsumer | BullMQ @Processor/WorkerHost consumer; auto-discovered jobs, duplicate prevention, recurring schedulers, 1-day TTL, retries, failures persisted to sys_failed_jobs |
| Scheduled events | SystemScheduledTaskEntity, ScheduledEventEntity, SetScheduledEventJob, DispatchScheduledEventJob | schedule definitions in sys_scheduled_tasks → events in sys_scheduled_events; worker-driven materialisation + dispatch via SCHEDULED_EVENT_* env |
| Auth | AuthModule, AuthService.validateAccess(), AuthContextService, AuthContextMiddleware, GoogleAuthService | imperative auth (open by default); bearer token via AsyncLocalStorage → user + roles; access + rotating refresh tokens, Redis JTI blocklist; Google SSO via ID token at POST /auth/google |
| API keys | ApiKeyService, ApiKeyGuard, @ApiKeyScopes() | hashed keys in sys_api_keys, scopes, expiry/revocation, x-api-key header |
| Multi-tenancy | TenantModule, TenantEntity, TenantCommonEntity, TenantContextService, TenantContextMiddleware | AsyncLocalStorage tenant context; resolve by header, slug, or domain |
| Admin | AdminModule, AdminAccessService, TenantDataProcessor, ClientUserDataProcessor, BulkUserDataProcessor, AdminUserDataProcessor, list processors | back-office APIs: tenant onboarding, client/admin user registration (single + bulk), tenant + scheduled-task listing; 4-tier GlobalAdminRoles gate; UserType discriminator on sys_users; Swagger at /docs (hidden in production) |
| Notifications | NotificationModule, NotificationService, FcmPushNotificationService | in-app rows + FCM push (optional firebase-admin); device token registry |
| Email (SES) | EmailNotificationService, ProcessEmailNotification, EmailTemplateEntity, EmailNotificationLogEntity | templates in sys_email_templates rendered by a remote service, dispatched via SES per recipient (to/cc/bcc); logged to sys_email_notification_logs outside production; dispatch skipped when AWS_SES_FROM_ADDRESS unset |
| Audit | AuditService | saveAuditLog(old, new) with changed-value diffing into sys_audit_logs |
| Feature flags | FeatureFlagService | on/off, percentage rollouts, allow lists — stored as feature.* system properties |
| Cache | CacheService | Redis, TTL objects ({days, hours, min}), transparent encryption |
| Encryption | EncryptionService | AES-256-GCM, scrypt-derived keys |
| System properties | SystemPropertyModule, SystemPropertyService | cached + optionally encrypted key/value settings in sys_properties |
| Documents | DocumentModule, DocumentService, S3BucketDocumentService, MinioBucketDocumentService | uploadDocument(multerFiles) → links[]; driver chosen via DOCUMENT_STORAGE_DRIVER |
| HTTP | CommonController, GlobalExceptionFilter, common DTOs | health endpoint base controller, uniform error envelope |
| Raw SQL | SqlService, SqlTarget | named :key placeholders always bound as driver params, :...key array expansion; runs on the read replica (DB_READ_*) with transparent primary fallback; fetchOne/fetchScalar/exists/count/paginate helpers |
| List endpoints | CommonListProcessor, CommonListFilterDto, FilterDto, FILTER_OPERATORS | allowlisted columns, parameterised operators (eq…isNull), typed value casting (str/num/date/bool/null), multi-sort + paginated on the read replica |
| Outbound HTTP | RemoteRequestService, ApiRequestTypeEnum, ApiContentTypeEnum | injectable fetch wrapper (global via CommonModule); getApiResponse<T>() parses JSON/text and throws OperationException on non-2xx, getApiRawResponse() returns the raw Response; JSON + form-urlencoded body serialisation |
| Observability | LoggingInterceptor, MonitoringInterceptor, MonitoringService | Sentry auto-registers when @sentry/node + SENTRY_DSN present; pluggable MonitoringAdapter |
| Jobs/Events | CommonJob, JobConsumer, CommonSubscriber, CommonDataProcessor | dispatch/handle separation, discovery-based registration, worker-event lifecycle, column-change detection, field error collection |
| Constants & maps | baseEntityConstants, baseJobConstants, baseEntityMaps, baseJobMaps | centralised dotted identifiers (as const) plus identifier → class maps of every concrete entity and job (abstract bases excluded); map keys are computed from the constants, so renames propagate at compile time |
Environment Variables
Copy .env.example into your project. Every variable is optional
unless marked required.
Application
| Variable | Default | Description |
| ------------ | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| SERVER_ENV | development | Runtime environment (development / staging / production / test). Swagger docs at /docs are mounted in every value except production. |
| PORT | 8000 | HTTP port the server listens on |
Database (PostgreSQL)
| Variable | Default | Description |
| ------------- | ----------- | ----------------- |
| DB_HOST | localhost | Postgres host |
| DB_PORT | 5432 | Postgres port |
| DB_USERNAME | postgres | Postgres user |
| DB_PASSWORD | postgres | Postgres password |
| DB_NAME | myapp_db | Database name |
Read replica (optional, used by SqlService and the list processors for read
queries). Comma-separate DB_READ_HOST for multiple replicas; unset DB_READ_*
values fall back to the primary's. Leave DB_READ_HOST empty to route replica
queries to the primary.
| Variable | Default | Description |
| ------------------ | -------------- | ---------------------- |
| DB_READ_HOST | (empty) | Replica host(s) |
| DB_READ_PORT | DB_PORT | Replica port |
| DB_READ_USERNAME | DB_USERNAME | Replica user |
| DB_READ_PASSWORD | DB_PASSWORD | Replica password |
| DB_READ_NAME | DB_NAME | Replica database name |
JWT / Auth
| Variable | Default | Description |
| ---------------------- | ------- | ------------------------------------------------------------- |
| ACCESS_TOKEN_SECRET | — | Required. Secret for signing access tokens. Min 32 chars |
| REFRESH_TOKEN_SECRET | — | Required. Secret for signing refresh tokens. Min 32 chars |
| ACCESS_TOKEN_EXPIRY | 1h | Access token lifetime (60s, 15m, 1h, 7d, …) |
| REFRESH_TOKEN_EXPIRY | 30d | Refresh token lifetime |
| GOOGLE_CLIENT_ID | (empty) | OAuth client id whose ID tokens are accepted at POST /auth/google. Empty disables Google SSO |
Authentication (imperative)
Controllers are open by default; you authenticate explicitly inside the
handler with AuthService.validateAccess(). It reads the bearer token from the
request (held in AsyncLocalStorage by AuthContextMiddleware), verifies it,
rejects revoked tokens, and returns the user row with its roles — throwing
AccessException (403) when missing/invalid. This keeps authorization next to
your business logic, so layering tenant/plan/role checks is just more code.
AuthController (mounted at /auth) exposes the ready-made flows: POST
/auth/login, /auth/google, /auth/register, /auth/refresh, /auth/logout,
/auth/change-password, and GET /auth/me. Passwords are hashed/verified via
AuthUtility (bcrypt); the write flows use the processor lifecycle.
Google SSO — the client posts a Google ID token as { id_token } to
POST /auth/google. GoogleAuthService verifies it against Google's tokeninfo
endpoint (audience must match GOOGLE_CLIENT_ID, issuer accounts.google.com,
email verified), then GoogleUserDataProcessor reuses the matching user or
provisions a tenant user (no password — password login stays disabled until one
is set) and the standard issueTokens() flow returns the app's own token pair.
@Controller('reports')
export class ReportController {
constructor(private readonly authService: AuthService) {}
@Get()
async list() {
const { user, roles } = await this.authService.validateAccess();
// ...custom checks: roles, tenant membership, subscription, ownership
}
}Wire the middleware once in your app so the token is captured per request
(mirrors TenantContextMiddleware):
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer.apply(AuthContextMiddleware).forRoutes('*'); // Express 5: e.g. '{*splat}'
}
}validateAccess() reads the token via AuthContextService (no request-scoped
providers). The Passport JwtStrategy/JwtAuthGuard remain available if you
prefer guard-based protection on specific routes, but they're no longer the
default path.
Encryption
| Variable | Default | Description |
| ------------------- | ------- | ------------------------------------------------------------------------------------- |
| ENCRYPTION_SECRET | — | Required when EncryptionService is used. AES-256-GCM key material. Min 32 chars |
Cache (Redis)
| Variable | Default | Description |
| ---------------------- | ----------- | ----------------------------------------- |
| CACHE_REDIS_HOST | localhost | Redis host for CacheService |
| CACHE_REDIS_PORT | 6379 | Redis port |
| CACHE_REDIS_PASSWORD | (empty) | Redis password |
| CACHE_REDIS_DB | 1 | Redis DB index (keep separate from queue) |
Queue (BullMQ / Redis)
| Variable | Default | Description |
| ---------------------- | ------------- | --------------------------------------------------------------------------------------------------- |
| BULL_REDIS_HOST | localhost | Redis host for BullMQ |
| BULL_REDIS_PORT | 6379 | Redis port |
| BULL_REDIS_PASSWORD | (empty) | Redis password |
| BULL_REDIS_DB | 0 | Redis DB index |
| BULL_QUEUE_NAME | myapp-queue | Name of the BullMQ queue |
| BULL_WORKERS | 2 | Concurrent worker threads per process |
| BULL_QUEUE_WORKER | false | Set true on consumer instances only — API servers should leave this false |
| BULL_JOB_TTL_SECONDS | 86400 | Retention (seconds) for completed/failed jobs before removal from the queue + Redis (default 1 day) |
| MAX_JOB_RETRIES | 3 | Max attempts per job (BullMQ attempts default) and the scheduled-event retry ceiling |
| SCHEDULED_EVENT_HOUR | 1 | Hour component of the SetScheduledEventJob cadence |
| SCHEDULED_EVENT_MIN | 0 | Minute component of the cadence |
| SCHEDULED_EVENT_SEC | 0 | Second component of the cadence (all three are summed; all-zero disables it) |
Jobs
A job pairs a dispatch side (enqueue work) with a handle side (process it).
Extend CommonJob, add it to your module's providers, and inject QueueService:
@Injectable()
export class WelcomeEmailJob extends CommonJob {
constructor(protected readonly queueService: QueueService) {
super('welcome.email.job'); // unique identifier
}
async handle(data: { userId: string }): Promise<void> {
// ...send the email
}
}
// elsewhere: await welcomeEmailJob.dispatch({ userId });CommonJob has no onModuleInit. At boot QueueService uses Nest's
DiscoveryService to find every CommonJob provider and register its handler
by identifier — so simply providing the job is enough; nothing self-registers.
Registration is guarded: two different classes claiming the same identifier
abort boot (no silent shadowing), and the discovered registry is validated
against baseJobMaps (merged with QueueModule.register({ jobMaps }) for app
jobs) — a mapped job with no provider fails fast at startup instead of
surfacing as a consume-time "not registered" error on the worker.
For database-event jobs (dispatched by a CommonSubscriber), CommonJob
exposes isNewRecord(event), isColumnUpdated(event, col) and
isAnyColumnUpdated(event, cols) — usable directly inside handle() (they work
on the plain event object after the queue round-trip).
Consumer / worker. A single JobConsumer (a BullMQ @Processor /
WorkerHost) is the only worker. On instances where BULL_QUEUE_WORKER=true it
processes every job — routing each to the handler registered under
job.data.job — tracks queue stats via worker lifecycle events
(active/completed/failed), and persists final failures (after
MAX_JOB_RETRIES attempts) to sys_failed_jobs. The worker is created with
autorun: false and only starts consuming on onApplicationBootstrap, after
the job registry is populated and validated — so a restart against a non-empty
queue can never pick up jobs before their handlers exist. Failed chained
dispatches (nextQueuePayload links) are persisted to sys_failed_jobs too.
Anything in that table can be re-dispatched with
queueService.retryFailedJob(id) (records are stamped once re-dispatched;
pass force to repeat) — this is also the recovery path for
duplicate-prevented jobs, which are removed from Redis on failure and are
therefore invisible to retryJob(jobId). API instances leave
BULL_QUEUE_WORKER=false so no worker spawns; they can still dispatch().
Scheduled events
Recurring work is event-driven (off the BullMQ worker), not a reconciliation loop. Two tables back it:
| Table | Entity | Role |
| ---------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| sys_scheduled_tasks | SystemScheduledTaskEntity | a schedule definition (identifier, name, start_from, schedule_interval, enabled, next_upsert_at) |
| sys_scheduled_events | ScheduledEventEntity | one row per fire time (job_id → task, scheduled_at, started_at, failed_at, total_attempts, active); one task → many events |
At application bootstrap (once the job registry is populated), JobConsumer
enrols SetScheduledEventJob as a
recurring scheduler whose cadence comes from SCHEDULED_EVENT_{HOUR,MIN,SEC}
(summed to an interval; all-zero disables it). BullMQ keys the scheduler on the
job identifier, so re-running on each boot upserts rather than duplicates.
Each tick, SetScheduledEventJob.handle() materialises due
sys_scheduled_events rows from enabled sys_scheduled_tasks (using
ScheduleUtility.addInterval) and triggers DispatchScheduledEventJob, which
enqueues each due event's job (bounded by MAX_JOB_RETRIES).
SystemScheduledTaskJob (via SystemScheduledTaskSubscriber) keeps events in
step with task changes — toggling active when a task is enabled/disabled and
soft-deleting events when a task is removed.
When a ScheduledEventEntity row is written, ScheduledEventSubscriber fires
ScheduledEventJob with the database event — wire your per-event dispatch logic
into that job's handle().
Document Storage
| Variable | Default | Description |
| --------------------------------------- | ------- | ------------------------------------------ |
| DOCUMENT_STORAGE_DRIVER | minio | Active driver: s3 or minio |
| DOCUMENT_PRESIGNED_URL_EXPIRY_SECONDS | 3600 | Presigned download URL validity in seconds |
S3 driver (DOCUMENT_STORAGE_DRIVER=s3):
| Variable | Default | Description |
| ------------------------- | ----------- | --------------------------------------------------------------- |
| AWS_S3_BUCKET | — | Required. S3 bucket name |
| AWS_S3_REGION | us-east-1 | AWS region |
| AWS_ACCESS_KEY_ID | — | AWS access key (falls back to instance profile / env chain) |
| AWS_SECRET_ACCESS_KEY | — | AWS secret key |
| AWS_S3_ENDPOINT | (empty) | Custom endpoint for S3-compatible providers |
| AWS_S3_PUBLIC_URL | (empty) | CDN base URL for public files |
| AWS_S3_FORCE_PATH_STYLE | false | Force path-style URLs (required by some S3-compatible services) |
MinIO driver (DOCUMENT_STORAGE_DRIVER=minio):
| Variable | Default | Description |
| ------------------ | ------------ | ------------------------------------------- |
| MINIO_ENDPOINT | localhost | MinIO host |
| MINIO_PORT | 9000 | MinIO port |
| MINIO_USE_SSL | false | Enable TLS |
| MINIO_ACCESS_KEY | minioadmin | MinIO access key |
| MINIO_SECRET_KEY | minioadmin | MinIO secret key |
| MINIO_BUCKET | documents | Bucket name (auto-created if missing) |
| MINIO_PUBLIC_URL | (empty) | CDN base URL when MinIO sits behind a proxy |
HTTP Logging
| Variable | Default | Description |
| ----------------- | ------- | --------------------------------------------------------------------------- |
| HTTP_LOG_FORMAT | dev | Morgan log format: dev (compact) or combined (includes IP + user agent) |
Monitoring
| Variable | Default | Description |
| ---------------------------- | --------- | -------------------------------------------------------------------------------------- |
| SENTRY_DSN | (empty) | Sentry DSN — Sentry is only activated when this is set and @sentry/node is installed |
| SENTRY_TRACES_SAMPLE_RATE | 0 | Sentry performance traces sample rate (0–1) |
| MONITORING_SLOW_REQUEST_MS | 3000 | Requests slower than this (ms) are flagged as warnings |
Multi-tenancy
| Variable | Default | Description |
| -------------------- | ------------- | ----------------------------------------------- |
| TENANT_HEADER_NAME | x-tenant-id | Request header carrying the tenant UUID or slug |
API Keys
| Variable | Default | Description |
| --------------------- | ----------- | ----------------------------------- |
| API_KEY_HEADER_NAME | x-api-key | Request header carrying the API key |
Email Notifications (AWS SES)
Credentials resolve through the standard AWS SDK chain. When
AWS_SES_FROM_ADDRESS is unset, dispatch is skipped with a warning — safe by
default in development.
| Variable | Default | Description |
| ---------------------- | ----------- | ------------------------------------------- |
| AWS_SES_FROM_ADDRESS | (empty) | Sender address; empty skips actual dispatch |
| AWS_SES_REGION | us-east-1 | SES region (falls back to AWS_REGION) |
Push Notifications (FCM)
Requires the optional peer dependency firebase-admin. Provide one of:
| Variable | Default | Description |
| ---------------------------- | --------- | ----------------------------------------------------------------------------- |
| FCM_SERVICE_ACCOUNT_PATH | (empty) | Absolute path to the Firebase service account JSON file |
| FCM_SERVICE_ACCOUNT_BASE64 | (empty) | Base64-encoded service account JSON (preferred for containerised deployments) |
Document Uploads in 10 Lines
@Post('attachments')
@UseInterceptors(FilesInterceptor('files'))
async upload(@UploadedFiles() files: Express.Multer.File[], @CurrentUser() user: RequestUser) {
const fileLinks = await this.documentService.uploadDocument(files, {
pathPrefix: 'attachments',
persist: true, // also records rows in sys_document_details
createdBy: user.userId,
sourceType: 'ticket',
sourceId: ticketUuid,
});
return { file_links: fileLinks };
}Set DOCUMENT_STORAGE_DRIVER=minio for a local MinIO bucket (auto-created) or
DOCUMENT_STORAGE_DRIVER=s3 for Amazon S3. Switching drivers requires no code change.
Raw SQL (SqlService)
SqlService (global via CommonModule) runs raw SQL with named :key
placeholders that are always bound as driver parameters — never
interpolated. :...key expands an array for IN clauses; a placeholder without
a matching param key throws OperationException before anything reaches the
database. Queries run on the read replica by default (DB_READ_* env), falling
back transparently to the primary when no replica is configured; use
executeSqlOnPrimary() for writes or lag-sensitive reads.
const users = await this.sqlService.executeSql<{ id: string; name: string }>(
`select u.id, u.full_name name from sys_users u
where u.business_id = :business_id and u.status in (:...statuses)`,
{ business_id: 42, statuses: ['active', 'invited'] },
);Helpers: fetchOne, fetchScalar, fetchColumn, exists, count,
paginate(sql, params, { page, limit }) (bound LIMIT/OFFSET + parallel count →
{ data, total, page, limit, total_pages }), and ping.
Outbound HTTP (RemoteRequestService)
A thin injectable fetch wrapper (global via CommonModule). Requests are
described by one options object; getApiResponse<T>() returns the parsed body
(JSON or text) and throws OperationException on non-2xx, while
getApiRawResponse() hands back the raw Response. Bodies are serialised per
ApiContentTypeEnum (JSON or form-urlencoded); GET never sends a body.
const data = await this.remoteRequestService.getApiResponse<MyDto>({
url: 'https://api.example.com/v1/items',
requestType: ApiRequestTypeEnum.PATCH,
body: { active: true },
headers: { Authorization: `Bearer ${token}` },
});Templated Email (AWS SES)
ProcessEmailNotification sends transactional email from templates registered
in sys_email_templates: the body is rendered by a remote template service
(base URL from the email.template.base.url system property, called through
RemoteRequestService), then dispatched via one SES SendEmailCommand per
recipient entry (to / cc / bcc). Outside production every send is logged to
sys_email_notification_logs first, and when AWS_SES_FROM_ADDRESS is unset
dispatch is skipped entirely — so development stays send-free by default.
await new ProcessEmailNotification(emailNotificationService).process({
identifier: 'user.welcome',
data: { name: 'Jane' }, // payload for the remote template renderer
emailRecepients: [{ recepient: '[email protected]', cc: ['[email protected]'] }],
});List Endpoints & Filtering
List endpoints are POST .../list routes backed by CommonListProcessor: a
subclass declares a base query plus an allowlist of filterable/sortable
columns, and the request body (CommonListFilterDto — always @Body, never
query params) carries pagination, filters and sorts. Column names
never come from the caller, operators resolve through the FILTER_OPERATORS
registry, and every value is bound as a parameter — nothing is interpolated
into the SQL.
Each FilterDto is { identifier, operator, value, value_type }. The raw
string value is cast per its declared value_type before binding:
| value_type | Cast |
| ------------ | ------------------------------------------------------------------------ |
| str | used as-is |
| num | Number(value) — rejects empty / non-numeric input |
| date | new Date(value) — rejects invalid dates |
| bool | case-insensitive 'true' / 'false' → real boolean, rejects everything else |
| null | bound as SQL NULL |
Operators: eq, ne, in, gt, gte, lt, lte, like, ilike,
between, isNull. Multi-value operators (in, between) take
comma-separated values and cast each part; isNull reads its value truthily
(false/0/empty → IS NOT NULL).
{
"page": 1,
"limit": 10,
"filters": [
{ "identifier": "tenant_name", "operator": "ilike", "value": "acme", "value_type": "str" },
{ "identifier": "is_active", "operator": "eq", "value": "true", "value_type": "bool" },
{ "identifier": "created_at", "operator": "between", "value": "2026-01-01, 2026-06-30", "value_type": "date" }
],
"sorts": [{ "identifier": "created_at", "direction": "DESC" }]
}Invalid identifiers, operators, or values raise OperationException before any
SQL runs. Results come back as { records, total, page, limit, total_pages }.
Admin Module
AdminModule is the back-office surface — tenant onboarding, client/admin user
registration, tenant listing and scheduled-task management. It uses the same
imperative auth model as the rest of the app: controllers are open by default and
gate each action through AdminAccessService before handing the work to a
processor.
Roles
Global roles (GlobalAdminRoles) are ordered by seniority — a senior role
satisfies any junior requirement:
| Role (sys_roles.name) | Capability |
| ------------------------ | ---------------------------------------------------- |
| global.super.admin | View/change/delete everything, including tenant info |
| global.admin | Tenant listing and admin-user management |
| global.content.manager | View/change/edit everything except tenant info |
| global.content.reader | Read-only access to non-tenant content |
AdminAccessService exposes validateSuperAdmin(), validateAdmin(),
validateContentManager() and validateContentReader(). Each authenticates the
caller and returns { user, roles } so the controller can pass the actor to a
processor.
Endpoints
| Route | Min. role | Processor |
| -------------------------------- | --------------- | ---------------------------- |
| POST /admin/tenants | super admin | TenantDataProcessor |
| POST /admin/tenants/list | admin | TenantListProcessor |
| POST /admin/users/clients | super admin | ClientUserDataProcessor |
| POST /admin/users/clients/bulk | super admin | BulkUserDataProcessor |
| POST /admin/users/admins | admin | AdminUserDataProcessor |
| POST /admin/scheduled-tasks | content manager | ScheduledTaskDataProcessor |
| POST /admin/scheduled-tasks/list | content reader | ScheduledTaskListProcessor |
List endpoints are POST .../list with the filter payload in the body (a
CommonListFilterDto) — see List Endpoints & Filtering.
Flow
@Post()
async onboard(@Body() dto: AddTenantDto) {
const { user } = await this.adminAccessService.validateSuperAdmin();
return new TenantDataProcessor(user).process(dto);
}Data processors extend CommonDataProcessor and are created per request
(new XxxDataProcessor(actor, ...deps).process(dto)); list processors return a
PaginatedResult. Bulk registration validates the entire batch before any insert.
Admins and client users share sys_users, distinguished by the UserType enum on
user_type (system.user vs tenant.user).
API docs (Swagger)
Interactive docs are served at GET /docs (JSON at /docs-json) in every
environment except production — main.ts only mounts Swagger when
SERVER_ENV !== 'production', so the schema is never exposed in prod.
License
MIT
