npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@flusys/nestjs-form-builder

v9.2.1

Published

Dynamic form builder module with schema versioning and access control

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.

npm version License: MIT


Installation

npm install @flusys/nestjs-form-builder @flusys/nestjs-shared @flusys/nestjs-core

1. 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-draft bump the form_result stamp 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 form for the company filter, so FormResultService also depends on the form stamp.
  • 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 in form_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 }}), or computed.<key> for a computed field defined earlier.
  • valueType (number | string | boolean) casts the result; a rule that fails at run time falls back to defaultValue instead 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 with form.invalid.computed.field and { 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 only defaultValue on submit and is refused on save until its computed fields are rebuilt in the builder.

License

MIT © FLUSYS