@flusys/nestjs-form-builder
v9.2.1
Published
Dynamic form builder module with schema versioning and access control
Maintainers
Readme
@flusys/nestjs-form-builder
Dynamic form management for NestJS — JSON schema storage, schema versioning, access control (PUBLIC / AUTHENTICATED / ACTION_GROUP / EMAIL_VERIFIED), draft submissions, and a server-side computed fields engine.
Installation
npm install @flusys/nestjs-form-builder @flusys/nestjs-shared @flusys/nestjs-core1. Module Registration
forRoot (sync)
Mode 1: Single Database
import { FormBuilderModule } from '@flusys/nestjs-form-builder';
FormBuilderModule.forRoot({
global: true,
includeController: true,
bootstrapAppConfig: {
databaseMode: 'single',
enableCompanyFeature: false,
},
config: {
defaultDatabaseConfig: {
type: 'mysql',
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT ?? 3306),
username: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
},
},
});Mode 2: Multi-Tenant
FormBuilderModule.forRoot({
global: true,
includeController: true,
bootstrapAppConfig: {
databaseMode: 'multi-tenant',
enableCompanyFeature: true,
},
config: {
tenantDefaultDatabaseConfig: {
type: 'mysql',
host: process.env.TENANT_DB_HOST,
port: Number(process.env.TENANT_DB_PORT ?? 3306),
username: process.env.TENANT_DB_USER,
password: process.env.TENANT_DB_PASSWORD,
database: process.env.TENANT_DB_NAME,
},
tenants: [
{ id: 'tenant-a', database: 'tenant_a_db' },
{ id: 'tenant-b', database: 'tenant_b_db' },
],
},
});forRootAsync (factory — recommended)
import { ConfigModule, ConfigService } from '@nestjs/config';
import { FormBuilderModule, ITenantDatabaseConfig } from '@flusys/nestjs-form-builder';
// Single database
FormBuilderModule.forRootAsync({
global: true,
bootstrapAppConfig: {
databaseMode: 'single',
enableCompanyFeature: true,
},
imports: [ConfigModule],
useFactory: (cfg: ConfigService) => ({
defaultDatabaseConfig: {
type: 'mysql',
host: cfg.get('DB_HOST'),
port: cfg.get<number>('DB_PORT'),
username: cfg.get('DB_USER'),
password: cfg.get('DB_PASSWORD'),
database: cfg.get('DB_NAME'),
},
}),
inject: [ConfigService],
});
// Multi-tenant
FormBuilderModule.forRootAsync({
global: true,
bootstrapAppConfig: {
databaseMode: 'multi-tenant',
enableCompanyFeature: true,
},
imports: [ConfigModule],
useFactory: (cfg: ConfigService) => ({
tenantDefaultDatabaseConfig: {
type: 'mysql',
host: cfg.get('TENANT_DB_HOST'),
port: cfg.get<number>('TENANT_DB_PORT'),
username: cfg.get('TENANT_DB_USER'),
password: cfg.get('TENANT_DB_PASSWORD'),
database: cfg.get('TENANT_DB_NAME'),
},
tenants: cfg.get<ITenantDatabaseConfig[]>('TENANTS'),
}),
inject: [ConfigService],
});Exported services (available for injection after registration):
FormService, FormResultService, FormEmailAuthService, FormBuilderConfigService, FormBuilderDataSourceProvider
2. Entities
import { getFormBuilderEntitiesByConfig } from '@flusys/nestjs-form-builder/entities';
TypeOrmModule.forRoot({
entities: [
...getFormBuilderEntitiesByConfig(false), // match enableCompanyFeature in bootstrapAppConfig
],
});| enableCompanyFeature | Entities registered |
| ---------------------- | --------------------------------------------------------- |
| false | Form, FormResult, FormEmailVerification |
| true | FormWithCompany, FormResult, FormEmailVerification |
The service selects the correct entity automatically at request time — no manual switching needed.
ACTION_GROUP permission rule
An ACTION_GROUP form stores permissionLogic — an ILogicNode AND / OR tree from @flusys/nestjs-shared/interfaces (up to MAX_PERMISSION_LOGIC_DEPTH nested groups and MAX_PERMISSION_LOGIC_CODES codes), validated with permissionLogicProblem() from @flusys/nestjs-shared/utils:
{
type: 'group',
operator: 'AND',
children: [
{ type: 'action', actionId: 'hr.survey.submit' },
{ type: 'group', operator: 'OR', children: [{ type: 'action', actionId: 'hr.manager' }, { type: 'action', actionId: 'hr.admin' }] },
],
}Fetching (form/authenticated/:id) and submitting check it with evaluatePermissionLogic() against the user's codes from loadPermissionCodes() (PERMISSION_RESOLVER, else SharedPermissionCacheService); when the codes cannot be read, access fails closed (403 form.permission.check.failed). A form with no rule is open to any logged-in user.
3. EMAIL_VERIFIED Access Type
A fourth FormAccessType, alongside PUBLIC/AUTHENTICATED/ACTION_GROUP, that lets an anonymous visitor prove ownership of an email via a one-time code before filling out an otherwise-public form. An already-logged-in visitor is auto-identified by their account email and skips the code entirely. The resolved email becomes a real server-side identity (FormResult.submitterEmail) that the existing responseMode form setting (single | multiple, in schema.settings) is enforced against — single blocks a second submission from the same email, multiple allows repeats without re-verifying (a 30-day sliding access-token session).
Wiring an email sender — like AUTH_EMAIL_PROVIDER, this is a Provider Interface: without it, EMAIL_VERIFIED forms simply can't send codes.
import { FORM_BUILDER_EMAIL_PROVIDER, type IFormBuilderEmailProvider } from '@flusys/nestjs-form-builder/interfaces';
import { EmailSendService } from '@flusys/nestjs-email';
const formBuilderEmailProvider: Provider = {
provide: FORM_BUILDER_EMAIL_PROVIDER,
useFactory: (emailSendService: EmailSendService): IFormBuilderEmailProvider => ({
async sendOtpEmail(email, code, formTitle, expiryMinutes) {
await emailSendService.sendTemplateEmail({
templateSlug: 'form-email-otp',
to: email,
variables: { code, formTitle, expiryMinutes: String(expiryMinutes) },
});
},
}),
inject: [EmailSendService],
};
FormBuilderModule.forRoot({
// ...
providers: [formBuilderEmailProvider],
});New endpoints:
| Endpoint | Guard | Purpose |
| --- | --- | --- |
| POST form-builder/form-email-auth/request-otp | @Public(), throttled 3/5min | Send a 6-digit code to an email for a given EMAIL_VERIFIED form |
| POST form-builder/form-email-auth/verify-otp | @Public(), throttled 10/min | Verify the code, returns an opaque accessToken (30-day sliding expiry) |
| POST form-builder/form/email-verified/:id | OptionalJwtGuard | Load the form schema; resolves identity from a JWT if present, else { accessToken } in the body |
| POST form-builder/result/submit-public | OptionalJwtGuard | Now also accepts EMAIL_VERIFIED forms — pass { accessToken } for anonymous visitors, nothing for JWT-authenticated ones |
| POST form-builder/result/has-submitted-email | OptionalJwtGuard | Anonymous-path counterpart to has-submitted, resolving identity the same way |
Single-response race: the "already submitted?" count and the insert are not atomic, so a non-draft submission to a single form claims form-builder:submit:[<tenant>:]<formId>:<email> (30 s, released when done) on the shared cache. A concurrent submission for the same form + email - on any instance with redis / hybrid cache - gets 409 form.result.already.submitted. When the cache is unreachable the count check alone applies.
Caching
FormService and FormResultService cache get-all / get/:id (ApiService version stamps, see nestjs-shared section 7):
- Submit, draft save / finalize and
update-draftbump theform_resultstamp after the write - also when the insert fails after an old draft was already soft-removed - and before the domain event is published. - With the company feature, cached result lists join
formfor the company filter, soFormResultServicealso depends on theformstamp. - Public, authenticated, email-verified and submission reads (
public/:id,access-info/:id,authenticated/:id,email-verified/:id,by-slug, submit) always read the database, so a deactivated or re-scoped form is never served from cache. OTP and access-token state lives only inform_email_verification, never in the cache.
4. Computed Fields
schema.settings.computedFields[] are calculated on final submission (not drafts) and stored under data._computed[key]. Each field has ordered rules - { condition?, value }, first match wins, else defaultValue - where value is an Expression and condition an expression condition group. Combinations (regex extract, then join with another field, then upper-case; inline if / else) are just nested expressions.
- References: a field id, a field
name(what templates use,{{ email }}), orcomputed.<key>for a computed field defined earlier. valueType(number|string|boolean) casts the result; a rule that fails at run time falls back todefaultValueinstead of failing the submission.- Saving a form validates its computed fields (
findComputedFieldIssue): key format and uniqueness, shape, unknown references or functions, argument counts, literal regex patterns. A problem is a 400 withform.invalid.computed.fieldand{ field, code, ... }. - Breaking (v9): the old rule shape (
computation: { type: 'direct' | 'field_reference' | 'arithmetic', config }with{ fieldId, comparison }conditions) is not read or converted. A form saved with it computes onlydefaultValueon submit and is refused on save until its computed fields are rebuilt in the builder.
License
MIT © FLUSYS
