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

v4.0.0-lts

Published

Dynamic form builder module with schema versioning and access control

Readme

Form Builder Package Guide

Package: @flusys/nestjs-form-builder Version: 3.0.1 Type: Dynamic form management with schema versioning and access control

This guide covers the NestJS form builder package - dynamic form creation, submission storage, and multi-tenant support.

Table of Contents


Overview

@flusys/nestjs-form-builder provides a comprehensive form management system:

  • Dynamic Forms - JSON schema-based form definitions
  • Schema Versioning - Auto-increment version on schema changes
  • Result Snapshots - Store schema at submission time for historical accuracy
  • Access Control - Public, authenticated, and permission-based access
  • Multi-Tenant Support - Optional company isolation
  • POST-only RPC - Follows project API conventions

Package Hierarchy

@flusys/nestjs-core     ← Foundation
     ↓
@flusys/nestjs-shared   ← Shared utilities
     ↓
@flusys/nestjs-form-builder  ← Form management (THIS PACKAGE)

Installation

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

Constants

// Injection Token
export const FORM_BUILDER_MODULE_OPTIONS = 'FORM_BUILDER_MODULE_OPTIONS';

Package Architecture

nestjs-form-builder/
├── src/
│   ├── modules/
│   │   └── form-builder.module.ts    # Main module
│   │
│   ├── config/
│   │   ├── form-builder.constants.ts # Constants
│   │   └── index.ts
│   │
│   ├── entities/
│   │   ├── form.entity.ts            # Main form entity
│   │   ├── form-with-company.entity.ts  # Extends Form with company
│   │   ├── form-result.entity.ts     # Submissions
│   │   └── index.ts
│   │
│   ├── dtos/
│   │   ├── form.dto.ts               # Form DTOs
│   │   ├── form-result.dto.ts        # Result DTOs
│   │   └── index.ts
│   │
│   ├── services/
│   │   ├── form-builder-config.service.ts  # Config service
│   │   ├── form-builder-datasource.provider.ts
│   │   ├── form.service.ts           # Form CRUD
│   │   ├── form-result.service.ts    # Submission handling
│   │   └── index.ts
│   │
│   ├── controllers/
│   │   ├── form.controller.ts        # Form endpoints
│   │   ├── form-result.controller.ts # Result endpoints
│   │   └── index.ts
│   │
│   ├── enums/
│   │   ├── form-access-type.enum.ts
│   │   └── index.ts
│   │
│   ├── interfaces/
│   │   ├── form.interface.ts
│   │   ├── form-result.interface.ts
│   │   ├── form-builder-module.interface.ts
│   │   └── index.ts
│   │
│   ├── utils/
│   │   ├── permission.utils.ts       # Permission validation
│   │   ├── computed-field.utils.ts   # Computed field calculation
│   │   └── index.ts
│   │
│   ├── docs/
│   │   ├── form-builder-swagger.config.ts
│   │   └── index.ts
│   │
│   └── index.ts                      # Public API

Module Setup

Basic Setup (Single Tenant)

import { FormBuilderModule } from '@flusys/nestjs-form-builder';

@Module({
  imports: [
    FormBuilderModule.forRoot({
      global: true,
      includeController: true,
      bootstrapAppConfig: {
        databaseMode: 'single',
        enableCompanyFeature: false,
      },
      config: {
        defaultDatabaseConfig: {
          host: 'localhost',
          port: 5432,
          username: 'postgres',
          password: 'password',
          database: 'flusys',
        },
      },
    }),
  ],
})
export class AppModule {}

With Company Feature

FormBuilderModule.forRoot({
  bootstrapAppConfig: {
    databaseMode: 'single',
    enableCompanyFeature: true,  // Enable company isolation
  },
  config: {
    defaultDatabaseConfig: dbConfig,
  },
})

Async Configuration

FormBuilderModule.forRootAsync({
  bootstrapAppConfig: {
    databaseMode: 'single',
    enableCompanyFeature: true,
  },
  useFactory: async (configService: ConfigService) => ({
    defaultDatabaseConfig: configService.getDatabaseConfig(),
  }),
  inject: [ConfigService],
})

Migration Configuration

Add form builder entities to your migration config:

import { getFormBuilderEntitiesByConfig } from '@flusys/nestjs-form-builder/entities';

function getEntitiesForTenant(tenantConfig?: ITenantDatabaseConfig): any[] {
  const enableCompany = tenantConfig?.enableCompanyFeature ?? false;

  // ... other entities
  const formBuilderEntities = getFormBuilderEntitiesByConfig(enableCompany);

  return [...otherEntities, ...formBuilderEntities];
}

Entities

Entity Groups

// Core entities (no company feature)
export const FormCoreEntities = [Form, FormResult];

// Company-specific entities
export const FormCompanyEntities = [FormWithCompany, FormResult];

// Helper function
export function getFormBuilderEntitiesByConfig(enableCompanyFeature: boolean): any[] {
  return enableCompanyFeature ? FormCompanyEntities : FormCoreEntities;
}

// Base type alias for backwards compatibility
export { Form as FormBase } from './form.entity';

Form

Main form entity with all form fields:

| Column | Type | Default | Description | |--------|------|---------|-------------| | name | varchar(255) | required | Form name | | description | varchar(500) | null | Optional description | | slug | varchar(255) | null | URL-friendly identifier | | schema | json | required | Form schema (sections, fields, settings) | | schemaVersion | int | 1 | Auto-incremented on schema changes | | accessType | varchar(50) | 'AUTHENTICATED' | public, authenticated, action_group | | actionGroups | simple-array | null | Permission codes for action_group access | | isActive | boolean | true | Form availability | | metadata | simple-json | null | Additional data |

Indexes (Form):

  • slug - Unique index
  • isActive - Index for filtering active forms

Form vs FormWithCompany

  • Form - Used when enableCompanyFeature: false
  • FormWithCompany - Extends Form, adds companyId column for tenant isolation

FormWithCompany Additional Column:

| Column | Type | Description | |--------|------|-------------| | companyId | uuid | Company ID for tenant isolation |

FormWithCompany Indexes:

  • companyId - Index for company filtering
  • companyId, slug - Unique compound index (slugs unique per company)
  • companyId, isActive - Compound index for active forms per company

FormResult

Stores form submissions:

| Column | Type | Default | Description | |--------|------|---------|-------------| | formId | uuid | required | Reference to form | | schemaVersionSnapshot | json | required | Full schema copy at submission time | | schemaVersion | int | required | Schema version at submission | | data | json | required | Submitted field values | | submittedById | uuid | null | User who submitted (null for public) | | submittedAt | timestamp | required | Submission timestamp | | isDraft | boolean | false | Draft vs final submission | | metadata | simple-json | null | Additional data |

Indexes:

  • formId - Index for filtering by form

Note: FormResult doesn't have companyId - company context is derived from the linked Form via formId. Company filtering is applied via JOIN in queries.


DTOs

Form DTOs

| DTO | Purpose | |-----|---------| | CreateFormDto | Create new form | | UpdateFormDto | Update existing form | | FormResponseDto | Full form response | | PublicFormResponseDto | Limited fields for public access | | FormAccessInfoResponseDto | Access requirements info |

CreateFormDto Fields

class CreateFormDto {
  name: string;                    // Required, max 255 chars
  description?: string;            // Optional, max 500 chars
  slug?: string;                   // Optional, max 255 chars (unique)
  schema: Record<string, unknown>; // Required - form structure
  accessType?: FormAccessType;     // Default: AUTHENTICATED
  actionGroups?: string[];         // For ACTION_GROUP access type
  companyId?: string;              // When company feature enabled
  isActive?: boolean;              // Default: true
  metadata?: Record<string, unknown>;
}

UpdateFormDto Fields

class UpdateFormDto extends PartialType(CreateFormDto) {
  id: string;                      // Required
  schemaVersion?: number;          // Auto-incremented on schema change
}

Form Result DTOs

| DTO | Purpose | |-----|---------| | SubmitFormDto | Public submission input | | CreateFormResultDto | Internal with extra fields | | UpdateFormResultDto | Update result | | GetMyDraftDto | Get user's draft for a form | | UpdateDraftDto | Update existing draft | | GetResultsByFormDto | Query results by form ID | | FormResultResponseDto | Result response |

SubmitFormDto Fields

class SubmitFormDto {
  formId: string;                  // Required
  data: Record<string, unknown>;   // Required - field values
  isDraft?: boolean;               // Default: false
  metadata?: Record<string, unknown>;
}

GetResultsByFormDto Fields

class GetResultsByFormDto {
  formId: string;                  // Required
  page?: number;                   // Default: 0
  pageSize?: number;               // Default: 10
}

Services

FormBuilderConfigService

Provides access to module configuration:

@Injectable()
export class FormBuilderConfigService implements IModuleConfigService {
  // Check if company feature is enabled (supports per-tenant override)
  isCompanyFeatureEnabled(tenant?: ITenantDatabaseConfig): boolean;

  // Get database mode ('single' | 'multi-tenant')
  getDatabaseMode(): DatabaseMode;

  // Check if running in multi-tenant mode
  isMultiTenant(): boolean;

  // Get full module options
  getOptions(): FormBuilderModuleOptions;

  // Get config section (defaultDatabaseConfig, tenants, etc.)
  getConfig(): IFormBuilderConfig | undefined;
}

FormService

Extends RequestScopedApiService with form-specific operations:

@Injectable({ scope: Scope.REQUEST })
export class FormService extends RequestScopedApiService<...> {
  // === Standard CRUD (inherited) ===
  // insert(dto, user), update(dto, user), delete(id, user), getAll(filter, user), getById(id, user)

  // === Public Access (no auth required) ===

  // Get form for public submission (accessType must be PUBLIC)
  async getPublicForm(formId: string): Promise<IPublicForm>;

  // Get public form by slug (no auth required)
  async getPublicFormBySlug(slug: string): Promise<IPublicForm | null>;

  // Get access info for routing decisions
  async getFormAccessInfo(formId: string): Promise<FormAccessInfoResponseDto>;

  // === Authenticated Access ===

  // Get form for authenticated submission (validates access + permissions)
  async getAuthenticatedForm(formId: string, user: ILoggedUserInfo): Promise<IPublicForm>;

  // Get form by slug (requires auth)
  async getBySlug(slug: string): Promise<IForm | null>;

  // Get form for submission (internal - validates access type)
  async getFormForSubmission(formId: string, user: ILoggedUserInfo | null): Promise<Form>;
}

Schema Versioning:

  • schemaVersion auto-increments when schema JSON changes
  • Comparison uses JSON.stringify for deep equality check
  • Version tracked in FormResult snapshots for historical accuracy

FormResultService

Handles form submissions and drafts:

@Injectable({ scope: Scope.REQUEST })
export class FormResultService extends RequestScopedApiService<...> {
  // === Standard CRUD (inherited) ===
  // insert(dto, user), update(dto, user), delete(id, user), getAll(filter, user), getById(id, user)

  // === Form Submission ===

  // Submit form (validates access type, handles drafts)
  async submitForm(
    dto: SubmitFormDto,
    user: ILoggedUserInfo | null,
    isPublic?: boolean,  // default: false
  ): Promise<IFormResult>;

  // === Draft Management ===

  // Get user's draft for a specific form
  async getMyDraft(formId: string, user: ILoggedUserInfo): Promise<IFormResult | null>;

  // Update existing draft (can convert to final submission)
  async updateDraft(
    draftId: string,
    dto: SubmitFormDto,
    user: ILoggedUserInfo,
  ): Promise<IFormResult>;

  // === Query Methods ===

  // Get results by form ID with pagination
  async getByFormId(
    formId: string,
    user: ILoggedUserInfo | null,
    pagination?: { page?: number; pageSize?: number },
  ): Promise<{ data: IFormResult[]; total: number }>;

  // Check if user has submitted (non-draft) for single response mode
  async hasUserSubmitted(formId: string, user: ILoggedUserInfo): Promise<boolean>;
}

Key behaviors:

  • Schema snapshot stored with each submission for historical accuracy
  • Drafts auto-update if user re-submits as draft
  • Final submission deletes existing draft (soft delete)
  • Computed fields applied only on final submission (not drafts)
  • Company filtering via JOIN when company feature enabled

FormBuilderDataSourceProvider

Extends MultiTenantDataSourceService for dynamic entity loading:

@Injectable({ scope: Scope.REQUEST })
export class FormBuilderDataSourceProvider extends MultiTenantDataSourceService {
  // Maintains separate static cache from other modules
  protected static readonly tenantConnections = new Map<string, DataSource>();
  protected static singleDataSource: DataSource | null = null;

  // Get entities based on company feature flag
  async getFormBuilderEntities(enableCompanyFeature?: boolean): Promise<any[]>;

  // Inherited from MultiTenantDataSourceService
  async getDataSource(): Promise<DataSource>;
  async getRepository<T>(entity: EntityTarget<T>): Promise<Repository<T>>;
}

Controllers

FormController

Base path: /form-builder/form

| Endpoint | Auth | Description | |----------|------|-------------| | POST /insert | JWT | Create form | | POST /update | JWT | Update form | | POST /delete | JWT | Delete form | | POST /get-all | JWT | List forms | | POST /get/:id | JWT | Get form by ID | | POST /access-info/:id | Public | Get access requirements | | POST /public/:id | Public | Get public form | | POST /authenticated/:id | JWT | Get authenticated form | | POST /by-slug/:slug | JWT | Get form by slug | | POST /public/by-slug/:slug | Public | Get public form by slug |

FormResultController

Base path: /form-builder/result

| Endpoint | Auth | Description | |----------|------|-------------| | POST /insert | JWT | Create result (internal) | | POST /update | JWT | Update result | | POST /delete | JWT | Delete result | | POST /get-all | JWT | List results | | POST /get/:id | JWT | Get result by ID | | POST /submit | JWT | Submit form (authenticated) | | POST /submit-public | Public | Submit form (public) | | POST /my-draft | JWT | Get user's draft for a form | | POST /update-draft | JWT | Update draft or convert to final | | POST /by-form | JWT | Get results by form ID | | POST /has-submitted | JWT | Check if user has submitted |


Access Control

Access Types

| Type | Description | Endpoint | |------|-------------|----------| | public | No authentication | submit-public | | authenticated | Login required | submit | | action_group | Specific permissions | submit + permission check |

Flow

  1. Frontend calls access-info/:id to determine requirements
  2. Based on accessType:
    • public → Fetch via public/:id, submit via submit-public
    • authenticated → Redirect to login if needed, use authenticated/:id, submit via submit
    • action_group → Same as authenticated + permission check on submit

Permission Checking

For action_group forms:

  • Form stores required permissions in actionGroups array
  • Service checks if user has ANY of the listed permissions
  • Uses cache-based permission lookup via validateUserPermissions
import { validateUserPermissions } from '@flusys/nestjs-form-builder';

// In FormService.getAuthenticatedForm()
if (form.accessType === FormAccessType.ACTION_GROUP && form.actionGroups?.length) {
  const hasPermission = await validateUserPermissions(
    user,
    form.actionGroups,
    this.cacheManager,
    this.formBuilderConfig.isCompanyFeatureEnabled(),
    this.logger,
    'accessing form',
    form.id,
  );
  if (!hasPermission) {
    throw new ForbiddenException('You do not have permission to access this form');
  }
}

Multi-Tenant Support

Configuration

FormBuilderModule.forRoot({
  bootstrapAppConfig: {
    enableCompanyFeature: true,
  },
})

Entity Selection

The module automatically selects the correct entity:

  • enableCompanyFeature: falseForm + FormResult
  • enableCompanyFeature: trueFormWithCompany + FormResult

Company Filtering

When company feature is enabled:

  • Forms are filtered by user.companyId
  • Results are filtered via JOIN to Form's companyId
  • New forms get companyId from user context or DTO

DataSource Provider

FormBuilderDataSourceProvider extends MultiTenantDataSourceService:

  • Maintains separate static cache from other modules
  • Dynamically loads correct entities per tenant
  • Supports per-tenant feature flags

API Reference

Module Options

interface FormBuilderModuleOptions extends IDynamicModuleConfig {
  global?: boolean;                          // Make module global
  includeController?: boolean;               // Include REST controllers
  bootstrapAppConfig?: IBootstrapAppConfig;  // Bootstrap configuration
  config?: IFormBuilderConfig;               // Form builder configuration
}

interface IFormBuilderConfig extends IDataSourceServiceOptions {
  // Currently no form-builder specific runtime config
  // Add form-builder specific settings here as needed
  defaultDatabaseConfig?: IDatabaseConfig;
  tenantDefaultDatabaseConfig?: IDatabaseConfig;
  tenants?: ITenantDatabaseConfig[];
}

Async Options

interface FormBuilderModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
  global?: boolean;
  includeController?: boolean;
  bootstrapAppConfig?: IBootstrapAppConfig;
  useFactory?: (...args: any[]) => Promise<IFormBuilderConfig> | IFormBuilderConfig;
  inject?: any[];
  useExisting?: Type<FormBuilderOptionsFactory>;
  useClass?: Type<FormBuilderOptionsFactory>;
}

interface FormBuilderOptionsFactory {
  createFormBuilderOptions(): Promise<IFormBuilderConfig> | IFormBuilderConfig;
}

Interfaces

interface IForm {
  id: string;
  name: string;
  description: string | null;
  slug: string | null;
  schema: Record<string, unknown>;
  schemaVersion: number;
  accessType: FormAccessType;
  actionGroups: string[] | null;
  isActive: boolean;
  companyId: string | null;
  metadata: Record<string, unknown> | null;
  createdAt: Date;
  updatedAt: Date;
  deletedAt: Date | null;
  createdById: string | null;
  updatedById: string | null;
  deletedById: string | null;
}

interface IFormResult {
  id: string;
  formId: string;
  schemaVersionSnapshot: Record<string, unknown>;
  schemaVersion: number;
  data: Record<string, unknown>;
  submittedById: string | null;
  submittedAt: Date;
  isDraft: boolean;
  metadata: Record<string, unknown> | null;
  // ... audit fields
}

interface IPublicForm {
  id: string;
  name: string;
  description: string | null;
  schema: Record<string, unknown>;
  schemaVersion: number;
}

Enums

enum FormAccessType {
  PUBLIC = 'public',
  AUTHENTICATED = 'authenticated',
  ACTION_GROUP = 'action_group',
}

Computed Fields

Computed fields are values automatically calculated from form responses when a form is submitted. The calculation happens server-side before storing the result.

How It Works

  1. Form schema contains computedFields in settings
  2. On final submission (not drafts), backend calculates values
  3. Computed values are stored in data._computed namespace
  4. Original field values remain unchanged

Utility Functions

import { calculateComputedFields, IComputedField } from '@flusys/nestjs-form-builder';

// Calculate computed fields from form data
const computedValues = calculateComputedFields(formData, computedFields);
// Result: { total_score: 15, category: 'premium' }

Note: Backend interface definitions mirror @flusys/ng-form-builder interfaces but are defined separately to avoid cross-package dependencies. Both use compatible JSON structures for serialization. See computed-field.utils.ts for implementation.

Computed Field Interfaces

interface IComputedField {
  id: string;
  name: string;
  key: string;                              // Storage key in _computed
  valueType: 'string' | 'number';
  rules: IComputedRule[];
  defaultValue?: string | number | null;
  description?: string;
}

interface IComputedRule {
  id: string;
  condition?: IComputedConditionGroup;      // Optional - no condition = always apply
  computation: IComputation;
}

interface IComputedConditionGroup {
  operator: 'AND' | 'OR';
  conditions: IComputedCondition[];
}

interface IComputedCondition {
  fieldId: string;
  comparison: string;                       // See Condition Operators below
  value: unknown;
}

interface IComputation {
  type: ComputationType;
  config: IDirectValueConfig | IFieldReferenceConfig | IArithmeticConfig;
}

type ComputationType = 'direct' | 'field_reference' | 'arithmetic';

Computation Config Types

// Direct value - set a static value
interface IDirectValueConfig {
  type: 'direct';
  value: string | number;
}

// Field reference - copy value from another field
interface IFieldReferenceConfig {
  type: 'field_reference';
  fieldId: string;
}

// Arithmetic - calculate from multiple operands
interface IArithmeticConfig {
  type: 'arithmetic';
  operation: ArithmeticOperation;
  operands: IArithmeticOperand[];
}

interface IArithmeticOperand {
  type: 'field' | 'constant';
  fieldId?: string;       // When type = 'field'
  value?: number;         // When type = 'constant'
}

Supported Operations

| Type | Description | |------|-------------| | direct | Set a static value | | field_reference | Copy value from another field | | arithmetic | Calculate using arithmetic operations |

Arithmetic Operations

| Operation | Description | |-----------|-------------| | sum | Add all operand values | | subtract | Subtract subsequent values from first | | multiply | Multiply all operand values | | divide | Divide first value by subsequent values | | average | Calculate average of all operands | | min | Get minimum value | | max | Get maximum value | | increment | Alias for sum | | decrement | Alias for subtract |

Condition Operators

Computed fields support conditional rules with these comparison operators:

| Operator | Aliases | Description | |----------|---------|-------------| | equals | | Value equality (string-safe) | | not_equals | | Value inequality | | contains | | String contains substring | | not_contains | | String does not contain substring | | starts_with | | String starts with value | | ends_with | | String ends with value | | greater_than | | Numeric greater than | | less_than | | Numeric less than | | greater_or_equal | | Numeric greater or equal | | less_or_equal | | Numeric less or equal | | is_empty | | Null, undefined, empty string, or empty array | | is_not_empty | | Has a non-empty value | | is_before | | Date comparison (before) | | is_after | | Date comparison (after) | | is_checked | | Boolean true (or 'true', or 1) | | is_not_checked | | Boolean false (or 'false', 0, falsy) | | is_any_of | in | Value is in array | | is_none_of | not_in | Value is not in array |

Data Storage

Submission data includes computed values:

{
  "formId": "uuid",
  "data": {
    "name": "John",
    "rating": 5,
    "_computed": {
      "total_score": 100,
      "satisfaction_level": "high"
    }
  }
}

Integration in Service

The FormResultService automatically calculates computed fields on submission via a private helper:

// Private method in FormResultService
private applyComputedFields(
  data: Record<string, unknown>,
  form: Form,
  isDraft: boolean,
): Record<string, unknown> {
  if (isDraft) return data;

  const schema = form.schema as Record<string, unknown>;
  const settings = schema?.settings as Record<string, unknown> | undefined;
  const computedFields = settings?.computedFields as IComputedField[] | undefined;

  if (!computedFields || computedFields.length === 0) return data;

  const computedValues = calculateComputedFields(data, computedFields);
  return { ...data, _computed: computedValues };
}

// Used in submitForm() and updateDraft()
const finalData = this.applyComputedFields(dto.data, form, isDraft);

Response Mode

The form schema supports a responseMode setting that controls whether users can submit multiple responses.

Settings

| Mode | Description | |------|-------------| | multiple | Default. Users can submit unlimited responses | | single | Each user can only submit once |

Tracking by Access Type

| Access Type | Tracking Method | Reliability | |-------------|-----------------|-------------| | authenticated | Server-side via submittedById | Reliable | | action_group | Server-side via submittedById | Reliable | | public | Client-side (frontend handles) | Best-effort only |

Backend Endpoint

For authenticated forms, the frontend calls hasUserSubmitted to check if the user has already submitted:

// FormResultController - POST /form-builder/result/has-submitted
@Post('has-submitted')
@UseGuards(JwtAuthGuard)
async hasUserSubmitted(
  @Body() dto: GetMyDraftDto, // { formId: string }
  @CurrentUser() user: ILoggedUserInfo,
): Promise<boolean> {
  return this.formResultService.hasUserSubmitted(dto.formId, user);
}

Note: Public forms cannot be reliably tracked server-side since there's no user identity. The frontend uses localStorage as a best-effort solution, but this can be bypassed. For strict single-response enforcement, use authenticated or action_group access type.


Best Practices

Schema Design

  • Store complete form schema including sections, fields, and settings
  • Use schema versioning to track changes
  • Store schema snapshots with results for historical accuracy

Access Control

  • Use public sparingly - only for truly anonymous forms
  • Prefer authenticated for most internal forms
  • Use action_group for sensitive forms requiring specific permissions

Company Isolation

  • Always set companyId when company feature is enabled
  • Use user's company context as default
  • Allow explicit companyId in DTO for admin operations

Performance

  • Use pagination when fetching results
  • Select only needed fields in queries
  • Consider caching frequently accessed forms

Swagger Configuration

The package includes a Swagger configuration helper that adapts documentation based on feature flags:

import { formBuilderSwaggerConfig } from '@flusys/nestjs-form-builder';
import { setupSwaggerDocs } from '@flusys/nestjs-core/docs';

// In bootstrap
const bootstrapConfig = { enableCompanyFeature: true };
setupSwaggerDocs(app, formBuilderSwaggerConfig(bootstrapConfig));

Features:

  • Automatically excludes companyId fields when company feature is disabled
  • Generates comprehensive API documentation
  • Documents all access types and workflows

Schema Exclusions

When enableCompanyFeature: false, these schemas hide companyId:

  • CreateFormDto
  • UpdateFormDto
  • FormQueryDto
  • FormResponseDto

Permission Utilities

The package provides permission validation utilities for action group access:

import { validateUserPermissions } from '@flusys/nestjs-form-builder';

// Validate user has at least one of the required permissions
const hasPermission = await validateUserPermissions(
  user,                           // ILoggedUserInfo
  ['hr.survey.submit', 'admin'],  // Required permissions (OR logic)
  cacheManager,                   // HybridCache instance
  enableCompanyFeature,           // boolean
  logger,                         // Logger instance
  'submitting form',              // Context for audit logging
  formId,                         // Resource ID for audit logging
);

Features:

  • Reads permissions from cache (same format as PermissionGuard)
  • Fail-closed behavior: cache errors result in access denial
  • Audit logging for permission denials

Permission Cache Key Format

// With company feature enabled
`permissions:company:${companyId}:branch:${branchId}:user:${userId}`

// Without company feature
`permissions:user:${userId}`

Controller Security

Both controllers use createApiController with permission-based security:

Form Permissions

| Operation | Permission | |-----------|------------| | Create | FORM_PERMISSIONS.CREATE | | Read | FORM_PERMISSIONS.READ | | Update | FORM_PERMISSIONS.UPDATE | | Delete | FORM_PERMISSIONS.DELETE |

Form Result Permissions

| Operation | Permission | |-----------|------------| | Create | FORM_RESULT_PERMISSIONS.CREATE | | Read | FORM_RESULT_PERMISSIONS.READ | | Update | FORM_RESULT_PERMISSIONS.UPDATE | | Delete | FORM_RESULT_PERMISSIONS.DELETE |

Note: Submit endpoints (submit, submit-public) don't require these permissions - they use the form's accessType for authorization.


See Also


Last Updated: 2026-02-25