@flusys/nestjs-form-builder 4.0.2 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +262 -876
- package/controllers/form-result.controller.d.ts +5 -0
- package/controllers/form.controller.d.ts +5 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,138 +1,85 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @flusys/nestjs-form-builder
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
> **Version:** 4.0.2
|
|
5
|
-
> **Type:** Dynamic form management with schema versioning and access control
|
|
3
|
+
> Dynamic form management for NestJS — JSON schema definitions, schema versioning, access control (PUBLIC/AUTHENTICATED/ACTION_GROUP), draft submissions, and a server-side computed fields engine.
|
|
6
4
|
|
|
7
|
-
|
|
5
|
+
[](https://www.npmjs.com/package/@flusys/nestjs-form-builder)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://nestjs.com/)
|
|
8
|
+
[](https://www.typescriptlang.org/)
|
|
9
|
+
[](https://nodejs.org/)
|
|
10
|
+
|
|
11
|
+
---
|
|
8
12
|
|
|
9
13
|
## Table of Contents
|
|
10
14
|
|
|
11
15
|
- [Overview](#overview)
|
|
16
|
+
- [Features](#features)
|
|
17
|
+
- [Compatibility](#compatibility)
|
|
12
18
|
- [Installation](#installation)
|
|
13
|
-
- [
|
|
14
|
-
- [
|
|
15
|
-
- [
|
|
19
|
+
- [Quick Start](#quick-start)
|
|
20
|
+
- [Module Registration](#module-registration)
|
|
21
|
+
- [forRoot (Sync)](#forroot-sync)
|
|
22
|
+
- [forRootAsync (Factory)](#forrootasync-factory)
|
|
23
|
+
- [Configuration Reference](#configuration-reference)
|
|
24
|
+
- [Feature Toggles](#feature-toggles)
|
|
25
|
+
- [API Endpoints](#api-endpoints)
|
|
16
26
|
- [Entities](#entities)
|
|
17
|
-
- [DTOs](#dtos)
|
|
18
|
-
- [Services](#services)
|
|
19
|
-
- [Controllers](#controllers)
|
|
20
27
|
- [Access Control](#access-control)
|
|
21
|
-
- [
|
|
22
|
-
- [
|
|
23
|
-
- [Computed Fields](#computed-fields)
|
|
24
|
-
- [
|
|
25
|
-
- [
|
|
26
|
-
- [
|
|
27
|
-
- [
|
|
28
|
-
- [Controller Security](#controller-security)
|
|
28
|
+
- [Schema Versioning](#schema-versioning)
|
|
29
|
+
- [Draft Support](#draft-support)
|
|
30
|
+
- [Computed Fields Engine](#computed-fields-engine)
|
|
31
|
+
- [Exported Services](#exported-services)
|
|
32
|
+
- [Programmatic Usage](#programmatic-usage)
|
|
33
|
+
- [Troubleshooting](#troubleshooting)
|
|
34
|
+
- [License](#license)
|
|
29
35
|
|
|
30
36
|
---
|
|
31
37
|
|
|
32
38
|
## Overview
|
|
33
39
|
|
|
34
|
-
`@flusys/nestjs-form-builder` provides a
|
|
35
|
-
|
|
36
|
-
- **Dynamic Forms** - JSON schema-based form definitions
|
|
37
|
-
- **Schema Versioning** - Auto-increment version on schema changes
|
|
38
|
-
- **Result Snapshots** - Store schema at submission time for historical accuracy
|
|
39
|
-
- **Access Control** - Public, authenticated, and permission-based access
|
|
40
|
-
- **Multi-Tenant Support** - Optional company isolation
|
|
41
|
-
- **POST-only RPC** - Follows project API conventions
|
|
42
|
-
|
|
43
|
-
### Package Hierarchy
|
|
44
|
-
|
|
45
|
-
```
|
|
46
|
-
@flusys/nestjs-core ← Foundation
|
|
47
|
-
↓
|
|
48
|
-
@flusys/nestjs-shared ← Shared utilities
|
|
49
|
-
↓
|
|
50
|
-
@flusys/nestjs-form-builder ← Form management (THIS PACKAGE)
|
|
51
|
-
```
|
|
40
|
+
`@flusys/nestjs-form-builder` provides a complete dynamic form system where form structures are stored as JSON in the database. New forms can be created without any code changes. The module handles schema versioning (submissions snapshot the schema they were submitted against), per-form access control, draft submissions, and server-side computed field evaluation.
|
|
52
41
|
|
|
53
42
|
---
|
|
54
43
|
|
|
55
|
-
##
|
|
44
|
+
## Features
|
|
56
45
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
46
|
+
- **Database-driven forms** — JSON schema stored in PostgreSQL; no code changes for new forms
|
|
47
|
+
- **Schema versioning** — Schema version auto-increments on change; each submission captures the version it used
|
|
48
|
+
- **Access types** — `PUBLIC` (no auth), `AUTHENTICATED` (any logged-in user), `ACTION_GROUP` (IAM permission-based)
|
|
49
|
+
- **Draft submissions** — Users can save partial form data and finalize later
|
|
50
|
+
- **Computed fields engine** — Server-side arithmetic computed from submission data using rules and conditions
|
|
51
|
+
- **Slug-based lookup** — Forms accessible by a URL-friendly slug
|
|
52
|
+
- **Company scoping** — Optional `companyId` isolation via `FormWithCompany` entity
|
|
53
|
+
- **Multi-tenant** — Per-tenant DataSource isolation
|
|
60
54
|
|
|
61
55
|
---
|
|
62
56
|
|
|
63
|
-
##
|
|
57
|
+
## Compatibility
|
|
64
58
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
59
|
+
| Package | Version |
|
|
60
|
+
|---------|---------|
|
|
61
|
+
| `@flusys/nestjs-core` | `^4.0.0` |
|
|
62
|
+
| `@flusys/nestjs-shared` | `^4.0.0` |
|
|
63
|
+
| `@nestjs/core` | `^11.0.0` |
|
|
64
|
+
| `typeorm` | `^0.3.0` |
|
|
65
|
+
| Node.js | `>= 18.x` |
|
|
69
66
|
|
|
70
67
|
---
|
|
71
68
|
|
|
72
|
-
##
|
|
69
|
+
## Installation
|
|
73
70
|
|
|
74
|
-
```
|
|
75
|
-
nestjs-form-builder/
|
|
76
|
-
├── src/
|
|
77
|
-
│ ├── modules/
|
|
78
|
-
│ │ └── form-builder.module.ts # Main module
|
|
79
|
-
│ │
|
|
80
|
-
│ ├── config/
|
|
81
|
-
│ │ ├── form-builder.constants.ts # Constants
|
|
82
|
-
│ │ └── index.ts
|
|
83
|
-
│ │
|
|
84
|
-
│ ├── entities/
|
|
85
|
-
│ │ ├── form.entity.ts # Main form entity
|
|
86
|
-
│ │ ├── form-with-company.entity.ts # Extends Form with company
|
|
87
|
-
│ │ ├── form-result.entity.ts # Submissions
|
|
88
|
-
│ │ └── index.ts
|
|
89
|
-
│ │
|
|
90
|
-
│ ├── dtos/
|
|
91
|
-
│ │ ├── form.dto.ts # Form DTOs
|
|
92
|
-
│ │ ├── form-result.dto.ts # Result DTOs
|
|
93
|
-
│ │ └── index.ts
|
|
94
|
-
│ │
|
|
95
|
-
│ ├── services/
|
|
96
|
-
│ │ ├── form-builder-config.service.ts # Config service
|
|
97
|
-
│ │ ├── form-builder-datasource.provider.ts
|
|
98
|
-
│ │ ├── form.service.ts # Form CRUD
|
|
99
|
-
│ │ ├── form-result.service.ts # Submission handling
|
|
100
|
-
│ │ └── index.ts
|
|
101
|
-
│ │
|
|
102
|
-
│ ├── controllers/
|
|
103
|
-
│ │ ├── form.controller.ts # Form endpoints
|
|
104
|
-
│ │ ├── form-result.controller.ts # Result endpoints
|
|
105
|
-
│ │ └── index.ts
|
|
106
|
-
│ │
|
|
107
|
-
│ ├── enums/
|
|
108
|
-
│ │ ├── form-access-type.enum.ts
|
|
109
|
-
│ │ └── index.ts
|
|
110
|
-
│ │
|
|
111
|
-
│ ├── interfaces/
|
|
112
|
-
│ │ ├── form.interface.ts
|
|
113
|
-
│ │ ├── form-result.interface.ts
|
|
114
|
-
│ │ ├── form-builder-module.interface.ts
|
|
115
|
-
│ │ └── index.ts
|
|
116
|
-
│ │
|
|
117
|
-
│ ├── utils/
|
|
118
|
-
│ │ ├── permission.utils.ts # Permission validation
|
|
119
|
-
│ │ ├── computed-field.utils.ts # Computed field calculation
|
|
120
|
-
│ │ └── index.ts
|
|
121
|
-
│ │
|
|
122
|
-
│ ├── docs/
|
|
123
|
-
│ │ ├── form-builder-swagger.config.ts
|
|
124
|
-
│ │ └── index.ts
|
|
125
|
-
│ │
|
|
126
|
-
│ └── index.ts # Public API
|
|
71
|
+
```bash
|
|
72
|
+
npm install @flusys/nestjs-form-builder @flusys/nestjs-shared @flusys/nestjs-core
|
|
127
73
|
```
|
|
128
74
|
|
|
129
75
|
---
|
|
130
76
|
|
|
131
|
-
##
|
|
77
|
+
## Quick Start
|
|
132
78
|
|
|
133
|
-
###
|
|
79
|
+
### Minimal Setup (Single Database)
|
|
134
80
|
|
|
135
81
|
```typescript
|
|
82
|
+
import { Module } from '@nestjs/common';
|
|
136
83
|
import { FormBuilderModule } from '@flusys/nestjs-form-builder';
|
|
137
84
|
|
|
138
85
|
@Module({
|
|
@@ -146,11 +93,12 @@ import { FormBuilderModule } from '@flusys/nestjs-form-builder';
|
|
|
146
93
|
},
|
|
147
94
|
config: {
|
|
148
95
|
defaultDatabaseConfig: {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
96
|
+
type: 'postgres',
|
|
97
|
+
host: process.env.DB_HOST,
|
|
98
|
+
port: Number(process.env.DB_PORT ?? 5432),
|
|
99
|
+
username: process.env.DB_USER,
|
|
100
|
+
password: process.env.DB_PASSWORD,
|
|
101
|
+
database: process.env.DB_NAME,
|
|
154
102
|
},
|
|
155
103
|
},
|
|
156
104
|
}),
|
|
@@ -159,906 +107,344 @@ import { FormBuilderModule } from '@flusys/nestjs-form-builder';
|
|
|
159
107
|
export class AppModule {}
|
|
160
108
|
```
|
|
161
109
|
|
|
162
|
-
|
|
110
|
+
After startup, create forms via `POST /form-builder/form/insert` and collect submissions via `POST /form-builder/result/insert`.
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Module Registration
|
|
115
|
+
|
|
116
|
+
### forRoot (Sync)
|
|
163
117
|
|
|
164
118
|
```typescript
|
|
165
119
|
FormBuilderModule.forRoot({
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
120
|
+
global?: boolean;
|
|
121
|
+
includeController?: boolean; // Default: true
|
|
122
|
+
bootstrapAppConfig?: {
|
|
123
|
+
databaseMode: 'single' | 'multi-tenant';
|
|
124
|
+
enableCompanyFeature: boolean; // true = FormWithCompany entity
|
|
125
|
+
};
|
|
126
|
+
config?: IFormBuilderConfig;
|
|
173
127
|
})
|
|
174
128
|
```
|
|
175
129
|
|
|
176
|
-
###
|
|
130
|
+
### forRootAsync (Factory)
|
|
177
131
|
|
|
178
132
|
```typescript
|
|
133
|
+
import { ConfigService } from '@nestjs/config';
|
|
134
|
+
|
|
179
135
|
FormBuilderModule.forRootAsync({
|
|
136
|
+
global: true,
|
|
137
|
+
includeController: true,
|
|
180
138
|
bootstrapAppConfig: {
|
|
181
139
|
databaseMode: 'single',
|
|
182
140
|
enableCompanyFeature: true,
|
|
183
141
|
},
|
|
184
|
-
|
|
185
|
-
|
|
142
|
+
imports: [ConfigModule],
|
|
143
|
+
useFactory: (configService: ConfigService) => ({
|
|
144
|
+
defaultDatabaseConfig: {
|
|
145
|
+
type: 'postgres',
|
|
146
|
+
host: configService.get('DB_HOST'),
|
|
147
|
+
port: configService.get<number>('DB_PORT'),
|
|
148
|
+
username: configService.get('DB_USER'),
|
|
149
|
+
password: configService.get('DB_PASSWORD'),
|
|
150
|
+
database: configService.get('DB_NAME'),
|
|
151
|
+
},
|
|
186
152
|
}),
|
|
187
153
|
inject: [ConfigService],
|
|
188
154
|
})
|
|
189
155
|
```
|
|
190
156
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
import { getFormBuilderEntitiesByConfig } from '@flusys/nestjs-form-builder/entities';
|
|
197
|
-
|
|
198
|
-
function getEntitiesForTenant(tenantConfig?: ITenantDatabaseConfig): any[] {
|
|
199
|
-
const enableCompany = tenantConfig?.enableCompanyFeature ?? false;
|
|
200
|
-
|
|
201
|
-
// ... other entities
|
|
202
|
-
const formBuilderEntities = getFormBuilderEntitiesByConfig(enableCompany);
|
|
203
|
-
|
|
204
|
-
return [...otherEntities, ...formBuilderEntities];
|
|
205
|
-
}
|
|
206
|
-
```
|
|
207
|
-
|
|
208
|
-
---
|
|
209
|
-
|
|
210
|
-
## Entities
|
|
211
|
-
|
|
212
|
-
### Entity Groups
|
|
213
|
-
|
|
214
|
-
```typescript
|
|
215
|
-
// Core entities (no company feature)
|
|
216
|
-
export const FormCoreEntities = [Form, FormResult];
|
|
217
|
-
|
|
218
|
-
// Company-specific entities
|
|
219
|
-
export const FormCompanyEntities = [FormWithCompany, FormResult];
|
|
220
|
-
|
|
221
|
-
// Helper function
|
|
222
|
-
export function getFormBuilderEntitiesByConfig(enableCompanyFeature: boolean): any[] {
|
|
223
|
-
return enableCompanyFeature ? FormCompanyEntities : FormCoreEntities;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// Base type alias for backwards compatibility
|
|
227
|
-
export { Form as FormBase } from './form.entity';
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
### Form
|
|
231
|
-
|
|
232
|
-
Main form entity with all form fields:
|
|
233
|
-
|
|
234
|
-
| Column | Type | Default | Description |
|
|
235
|
-
|--------|------|---------|-------------|
|
|
236
|
-
| `name` | `varchar(255)` | required | Form name |
|
|
237
|
-
| `description` | `varchar(500)` | null | Optional description |
|
|
238
|
-
| `slug` | `varchar(255)` | null | URL-friendly identifier |
|
|
239
|
-
| `schema` | `json` | required | Form schema (sections, fields, settings) |
|
|
240
|
-
| `schemaVersion` | `int` | 1 | Auto-incremented on schema changes |
|
|
241
|
-
| `accessType` | `varchar(50)` | 'AUTHENTICATED' | `public`, `authenticated`, `action_group` |
|
|
242
|
-
| `actionGroups` | `simple-array` | null | Permission codes for action_group access |
|
|
243
|
-
| `isActive` | `boolean` | true | Form availability |
|
|
244
|
-
| `metadata` | `simple-json` | null | Additional data |
|
|
245
|
-
|
|
246
|
-
**Indexes (Form):**
|
|
247
|
-
- `slug` - Unique index
|
|
248
|
-
- `isActive` - Index for filtering active forms
|
|
249
|
-
|
|
250
|
-
### Form vs FormWithCompany
|
|
251
|
-
|
|
252
|
-
- **Form** - Used when `enableCompanyFeature: false`
|
|
253
|
-
- **FormWithCompany** - Extends Form, adds `companyId` column for tenant isolation
|
|
254
|
-
|
|
255
|
-
**FormWithCompany Additional Column:**
|
|
256
|
-
|
|
257
|
-
| Column | Type | Description |
|
|
258
|
-
|--------|------|-------------|
|
|
259
|
-
| `companyId` | `uuid` | Company ID for tenant isolation |
|
|
260
|
-
|
|
261
|
-
**FormWithCompany Indexes:**
|
|
262
|
-
- `companyId` - Index for company filtering
|
|
263
|
-
- `companyId, slug` - Unique compound index (slugs unique per company)
|
|
264
|
-
- `companyId, isActive` - Compound index for active forms per company
|
|
265
|
-
|
|
266
|
-
### FormResult
|
|
267
|
-
|
|
268
|
-
Stores form submissions:
|
|
269
|
-
|
|
270
|
-
| Column | Type | Default | Description |
|
|
271
|
-
|--------|------|---------|-------------|
|
|
272
|
-
| `formId` | `uuid` | required | Reference to form |
|
|
273
|
-
| `schemaVersionSnapshot` | `json` | required | Full schema copy at submission time |
|
|
274
|
-
| `schemaVersion` | `int` | required | Schema version at submission |
|
|
275
|
-
| `data` | `json` | required | Submitted field values |
|
|
276
|
-
| `submittedById` | `uuid` | null | User who submitted (null for public) |
|
|
277
|
-
| `submittedAt` | `timestamp` | required | Submission timestamp |
|
|
278
|
-
| `isDraft` | `boolean` | false | Draft vs final submission |
|
|
279
|
-
| `metadata` | `simple-json` | null | Additional data |
|
|
280
|
-
|
|
281
|
-
**Indexes:**
|
|
282
|
-
- `formId` - Index for filtering by form
|
|
283
|
-
|
|
284
|
-
**Note:** FormResult doesn't have `companyId` - company context is derived from the linked Form via `formId`. Company filtering is applied via JOIN in queries.
|
|
157
|
+
**Exported services** (available for injection after registration):
|
|
158
|
+
- `FormBuilderConfigService`
|
|
159
|
+
- `FormBuilderDataSourceProvider`
|
|
160
|
+
- `FormService`
|
|
161
|
+
- `FormResultService`
|
|
285
162
|
|
|
286
163
|
---
|
|
287
164
|
|
|
288
|
-
##
|
|
289
|
-
|
|
290
|
-
### Form DTOs
|
|
291
|
-
|
|
292
|
-
| DTO | Purpose |
|
|
293
|
-
|-----|---------|
|
|
294
|
-
| `CreateFormDto` | Create new form |
|
|
295
|
-
| `UpdateFormDto` | Update existing form |
|
|
296
|
-
| `FormResponseDto` | Full form response |
|
|
297
|
-
| `PublicFormResponseDto` | Limited fields for public access |
|
|
298
|
-
| `FormAccessInfoResponseDto` | Access requirements info |
|
|
299
|
-
|
|
300
|
-
#### CreateFormDto Fields
|
|
301
|
-
|
|
302
|
-
```typescript
|
|
303
|
-
class CreateFormDto {
|
|
304
|
-
name: string; // Required, max 255 chars
|
|
305
|
-
description?: string; // Optional, max 500 chars
|
|
306
|
-
slug?: string; // Optional, max 255 chars (unique)
|
|
307
|
-
schema: Record<string, unknown>; // Required - form structure
|
|
308
|
-
accessType?: FormAccessType; // Default: AUTHENTICATED
|
|
309
|
-
actionGroups?: string[]; // For ACTION_GROUP access type
|
|
310
|
-
companyId?: string; // When company feature enabled
|
|
311
|
-
isActive?: boolean; // Default: true
|
|
312
|
-
metadata?: Record<string, unknown>;
|
|
313
|
-
}
|
|
314
|
-
```
|
|
315
|
-
|
|
316
|
-
#### UpdateFormDto Fields
|
|
317
|
-
|
|
318
|
-
```typescript
|
|
319
|
-
class UpdateFormDto extends PartialType(CreateFormDto) {
|
|
320
|
-
id: string; // Required
|
|
321
|
-
schemaVersion?: number; // Auto-incremented on schema change
|
|
322
|
-
}
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
### Form Result DTOs
|
|
326
|
-
|
|
327
|
-
| DTO | Purpose |
|
|
328
|
-
|-----|---------|
|
|
329
|
-
| `SubmitFormDto` | Public submission input |
|
|
330
|
-
| `CreateFormResultDto` | Internal with extra fields |
|
|
331
|
-
| `UpdateFormResultDto` | Update result |
|
|
332
|
-
| `GetMyDraftDto` | Get user's draft for a form |
|
|
333
|
-
| `UpdateDraftDto` | Update existing draft |
|
|
334
|
-
| `GetResultsByFormDto` | Query results by form ID |
|
|
335
|
-
| `FormResultResponseDto` | Result response |
|
|
336
|
-
|
|
337
|
-
#### SubmitFormDto Fields
|
|
165
|
+
## Configuration Reference
|
|
338
166
|
|
|
339
167
|
```typescript
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
168
|
+
interface IFormBuilderConfig extends IDataSourceServiceOptions {
|
|
169
|
+
// No form-builder-specific runtime config.
|
|
170
|
+
// IDataSourceServiceOptions provides:
|
|
171
|
+
// defaultDatabaseConfig?: IDatabaseConfig
|
|
172
|
+
// tenantDefaultDatabaseConfig?: IDatabaseConfig
|
|
173
|
+
// tenants?: ITenantDatabaseConfig[]
|
|
345
174
|
}
|
|
346
175
|
```
|
|
347
176
|
|
|
348
|
-
|
|
177
|
+
Bootstrap configuration:
|
|
349
178
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
pageSize?: number; // Default: 10
|
|
355
|
-
}
|
|
356
|
-
```
|
|
179
|
+
| Field | Type | Default | Effect |
|
|
180
|
+
|-------|------|---------|--------|
|
|
181
|
+
| `databaseMode` | `'single' \| 'multi-tenant'` | `'single'` | Controls DataSource resolution per request |
|
|
182
|
+
| `enableCompanyFeature` | `boolean` | `false` | Uses `FormWithCompany` entity when `true` |
|
|
357
183
|
|
|
358
184
|
---
|
|
359
185
|
|
|
360
|
-
##
|
|
361
|
-
|
|
362
|
-
### FormBuilderConfigService
|
|
363
|
-
|
|
364
|
-
Provides access to module configuration:
|
|
186
|
+
## Feature Toggles
|
|
365
187
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
isCompanyFeatureEnabled(tenant?: ITenantDatabaseConfig): boolean;
|
|
371
|
-
|
|
372
|
-
// Get database mode ('single' | 'multi-tenant')
|
|
373
|
-
getDatabaseMode(): DatabaseMode;
|
|
374
|
-
|
|
375
|
-
// Check if running in multi-tenant mode
|
|
376
|
-
isMultiTenant(): boolean;
|
|
377
|
-
|
|
378
|
-
// Get full module options
|
|
379
|
-
getOptions(): FormBuilderModuleOptions;
|
|
380
|
-
|
|
381
|
-
// Get config section (defaultDatabaseConfig, tenants, etc.)
|
|
382
|
-
getConfig(): IFormBuilderConfig | undefined;
|
|
383
|
-
}
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
### FormService
|
|
387
|
-
|
|
388
|
-
Extends `RequestScopedApiService` with form-specific operations:
|
|
389
|
-
|
|
390
|
-
```typescript
|
|
391
|
-
@Injectable({ scope: Scope.REQUEST })
|
|
392
|
-
export class FormService extends RequestScopedApiService<...> {
|
|
393
|
-
// === Standard CRUD (inherited) ===
|
|
394
|
-
// insert(dto, user), update(dto, user), delete(id, user), getAll(filter, user), getById(id, user)
|
|
395
|
-
|
|
396
|
-
// === Public Access (no auth required) ===
|
|
397
|
-
|
|
398
|
-
// Get form for public submission (accessType must be PUBLIC)
|
|
399
|
-
async getPublicForm(formId: string): Promise<IPublicForm>;
|
|
400
|
-
|
|
401
|
-
// Get public form by slug (no auth required)
|
|
402
|
-
async getPublicFormBySlug(slug: string): Promise<IPublicForm | null>;
|
|
403
|
-
|
|
404
|
-
// Get access info for routing decisions
|
|
405
|
-
async getFormAccessInfo(formId: string): Promise<FormAccessInfoResponseDto>;
|
|
406
|
-
|
|
407
|
-
// === Authenticated Access ===
|
|
408
|
-
|
|
409
|
-
// Get form for authenticated submission (validates access + permissions)
|
|
410
|
-
async getAuthenticatedForm(formId: string, user: ILoggedUserInfo): Promise<IPublicForm>;
|
|
411
|
-
|
|
412
|
-
// Get form by slug (requires auth)
|
|
413
|
-
async getBySlug(slug: string): Promise<IForm | null>;
|
|
414
|
-
|
|
415
|
-
// Get form for submission (internal - validates access type)
|
|
416
|
-
async getFormForSubmission(formId: string, user: ILoggedUserInfo | null): Promise<Form>;
|
|
417
|
-
}
|
|
418
|
-
```
|
|
419
|
-
|
|
420
|
-
**Schema Versioning:**
|
|
421
|
-
- `schemaVersion` auto-increments when schema JSON changes
|
|
422
|
-
- Comparison uses `JSON.stringify` for deep equality check
|
|
423
|
-
- Version tracked in FormResult snapshots for historical accuracy
|
|
424
|
-
|
|
425
|
-
### FormResultService
|
|
426
|
-
|
|
427
|
-
Handles form submissions and drafts:
|
|
428
|
-
|
|
429
|
-
```typescript
|
|
430
|
-
@Injectable({ scope: Scope.REQUEST })
|
|
431
|
-
export class FormResultService extends RequestScopedApiService<...> {
|
|
432
|
-
// === Standard CRUD (inherited) ===
|
|
433
|
-
// insert(dto, user), update(dto, user), delete(id, user), getAll(filter, user), getById(id, user)
|
|
434
|
-
|
|
435
|
-
// === Form Submission ===
|
|
436
|
-
|
|
437
|
-
// Submit form (validates access type, handles drafts)
|
|
438
|
-
async submitForm(
|
|
439
|
-
dto: SubmitFormDto,
|
|
440
|
-
user: ILoggedUserInfo | null,
|
|
441
|
-
isPublic?: boolean, // default: false
|
|
442
|
-
): Promise<IFormResult>;
|
|
443
|
-
|
|
444
|
-
// === Draft Management ===
|
|
445
|
-
|
|
446
|
-
// Get user's draft for a specific form
|
|
447
|
-
async getMyDraft(formId: string, user: ILoggedUserInfo): Promise<IFormResult | null>;
|
|
448
|
-
|
|
449
|
-
// Update existing draft (can convert to final submission)
|
|
450
|
-
async updateDraft(
|
|
451
|
-
draftId: string,
|
|
452
|
-
dto: SubmitFormDto,
|
|
453
|
-
user: ILoggedUserInfo,
|
|
454
|
-
): Promise<IFormResult>;
|
|
455
|
-
|
|
456
|
-
// === Query Methods ===
|
|
457
|
-
|
|
458
|
-
// Get results by form ID with pagination
|
|
459
|
-
async getByFormId(
|
|
460
|
-
formId: string,
|
|
461
|
-
user: ILoggedUserInfo | null,
|
|
462
|
-
pagination?: { page?: number; pageSize?: number },
|
|
463
|
-
): Promise<{ data: IFormResult[]; total: number }>;
|
|
464
|
-
|
|
465
|
-
// Check if user has submitted (non-draft) for single response mode
|
|
466
|
-
async hasUserSubmitted(formId: string, user: ILoggedUserInfo): Promise<boolean>;
|
|
467
|
-
}
|
|
468
|
-
```
|
|
469
|
-
|
|
470
|
-
**Key behaviors:**
|
|
471
|
-
- Schema snapshot stored with each submission for historical accuracy
|
|
472
|
-
- Drafts auto-update if user re-submits as draft
|
|
473
|
-
- Final submission deletes existing draft (soft delete)
|
|
474
|
-
- Computed fields applied only on final submission (not drafts)
|
|
475
|
-
- Company filtering via JOIN when company feature enabled
|
|
476
|
-
|
|
477
|
-
### FormBuilderDataSourceProvider
|
|
478
|
-
|
|
479
|
-
Extends `MultiTenantDataSourceService` for dynamic entity loading:
|
|
480
|
-
|
|
481
|
-
```typescript
|
|
482
|
-
@Injectable({ scope: Scope.REQUEST })
|
|
483
|
-
export class FormBuilderDataSourceProvider extends MultiTenantDataSourceService {
|
|
484
|
-
// Maintains separate static cache from other modules
|
|
485
|
-
protected static readonly tenantConnections = new Map<string, DataSource>();
|
|
486
|
-
protected static singleDataSource: DataSource | null = null;
|
|
487
|
-
|
|
488
|
-
// Get entities based on company feature flag
|
|
489
|
-
async getFormBuilderEntities(enableCompanyFeature?: boolean): Promise<any[]>;
|
|
490
|
-
|
|
491
|
-
// Inherited from MultiTenantDataSourceService
|
|
492
|
-
async getDataSource(): Promise<DataSource>;
|
|
493
|
-
async getRepository<T>(entity: EntityTarget<T>): Promise<Repository<T>>;
|
|
494
|
-
}
|
|
495
|
-
```
|
|
188
|
+
| Feature | Config | Default | Effect |
|
|
189
|
+
|---------|--------|---------|--------|
|
|
190
|
+
| Company scoping | `enableCompanyFeature: true` | `false` | All queries filtered by `companyId` from JWT; uses `FormWithCompany` entity |
|
|
191
|
+
| Multi-tenant | `databaseMode: 'multi-tenant'` | `'single'` | Per-tenant DataSource connections |
|
|
496
192
|
|
|
497
193
|
---
|
|
498
194
|
|
|
499
|
-
##
|
|
195
|
+
## API Endpoints
|
|
500
196
|
|
|
501
|
-
|
|
197
|
+
All endpoints use **POST**. Authentication depends on the form's `accessType`.
|
|
502
198
|
|
|
503
|
-
|
|
199
|
+
### Forms — `POST /form-builder/form/*`
|
|
504
200
|
|
|
505
201
|
| Endpoint | Auth | Description |
|
|
506
202
|
|----------|------|-------------|
|
|
507
|
-
| `POST /insert` |
|
|
508
|
-
| `POST /
|
|
509
|
-
| `POST /
|
|
510
|
-
| `POST /get-
|
|
511
|
-
| `POST /
|
|
512
|
-
| `POST /
|
|
513
|
-
| `POST /
|
|
514
|
-
| `POST /
|
|
515
|
-
| `POST /by-slug/:slug` | JWT | Get form by slug |
|
|
516
|
-
| `POST /public/by-slug/:slug` | Public | Get public form by slug |
|
|
203
|
+
| `POST /form-builder/form/insert` | `form.create` | Create a new form with JSON schema |
|
|
204
|
+
| `POST /form-builder/form/get-all` | `form.read` | List all forms (admin) |
|
|
205
|
+
| `POST /form-builder/form/get/:id` | `form.read` | Get form by ID |
|
|
206
|
+
| `POST /form-builder/form/get-by-slug/:slug` | Varies* | Get form by URL slug |
|
|
207
|
+
| `POST /form-builder/form/update` | `form.update` | Update form (increments schema version if schema changes) |
|
|
208
|
+
| `POST /form-builder/form/delete` | `form.delete` | Delete form |
|
|
209
|
+
| `POST /form-builder/form/publish` | `form.update` | Publish draft form |
|
|
210
|
+
| `POST /form-builder/form/unpublish` | `form.update` | Unpublish form |
|
|
517
211
|
|
|
518
|
-
|
|
212
|
+
*`get-by-slug` respects the form's `accessType` — PUBLIC forms are accessible without auth.
|
|
519
213
|
|
|
520
|
-
|
|
214
|
+
### Form Results (Submissions) — `POST /form-builder/result/*`
|
|
521
215
|
|
|
522
216
|
| Endpoint | Auth | Description |
|
|
523
217
|
|----------|------|-------------|
|
|
524
|
-
| `POST /insert` |
|
|
525
|
-
| `POST /
|
|
526
|
-
| `POST /
|
|
527
|
-
| `POST /
|
|
528
|
-
| `POST /
|
|
529
|
-
| `POST /
|
|
530
|
-
| `POST /
|
|
531
|
-
| `POST /my-
|
|
532
|
-
| `POST /update-draft` | JWT | Update draft or convert to final |
|
|
533
|
-
| `POST /by-form` | JWT | Get results by form ID |
|
|
534
|
-
| `POST /has-submitted` | JWT | Check if user has submitted |
|
|
218
|
+
| `POST /form-builder/result/insert` | Varies* | Submit a form result |
|
|
219
|
+
| `POST /form-builder/result/get-all` | `form-result.read` | List all submissions |
|
|
220
|
+
| `POST /form-builder/result/get/:id` | `form-result.read` | Get submission by ID |
|
|
221
|
+
| `POST /form-builder/result/update` | `form-result.update` | Update a submission |
|
|
222
|
+
| `POST /form-builder/result/delete` | `form-result.delete` | Delete a submission |
|
|
223
|
+
| `POST /form-builder/result/save-draft` | Varies* | Save a partial draft submission |
|
|
224
|
+
| `POST /form-builder/result/finalize` | Varies* | Convert draft to final submission |
|
|
225
|
+
| `POST /form-builder/result/get-my-results` | JWT | Get current user's own submissions |
|
|
535
226
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
## Access Control
|
|
227
|
+
*Depends on the form's `accessType`.
|
|
539
228
|
|
|
540
|
-
|
|
229
|
+
---
|
|
541
230
|
|
|
542
|
-
|
|
543
|
-
|------|-------------|----------|
|
|
544
|
-
| `public` | No authentication | `submit-public` |
|
|
545
|
-
| `authenticated` | Login required | `submit` |
|
|
546
|
-
| `action_group` | Specific permissions | `submit` + permission check |
|
|
231
|
+
## Entities
|
|
547
232
|
|
|
548
|
-
###
|
|
233
|
+
### Core Entities (always registered)
|
|
549
234
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
- `action_group` → Same as authenticated + permission check on submit
|
|
235
|
+
| Entity | Table | Description |
|
|
236
|
+
|--------|-------|-------------|
|
|
237
|
+
| `Form` | `form_builder_form` | Form definition with JSON schema, access type, version |
|
|
238
|
+
| `FormResult` | `form_builder_result` | Submission data + schema version snapshot |
|
|
555
239
|
|
|
556
|
-
###
|
|
240
|
+
### Company Feature Entities (`enableCompanyFeature: true`)
|
|
557
241
|
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
- Uses cache-based permission lookup via `validateUserPermissions`
|
|
242
|
+
| Entity | Table | Description |
|
|
243
|
+
|--------|-------|-------------|
|
|
244
|
+
| `FormWithCompany` | `form_builder_form` | Same as Form + `companyId` |
|
|
562
245
|
|
|
563
246
|
```typescript
|
|
564
|
-
import {
|
|
565
|
-
|
|
566
|
-
// In FormService.getAuthenticatedForm()
|
|
567
|
-
if (form.accessType === FormAccessType.ACTION_GROUP && form.actionGroups?.length) {
|
|
568
|
-
const hasPermission = await validateUserPermissions(
|
|
569
|
-
user,
|
|
570
|
-
form.actionGroups,
|
|
571
|
-
this.cacheManager,
|
|
572
|
-
this.formBuilderConfig.isCompanyFeatureEnabled(),
|
|
573
|
-
this.logger,
|
|
574
|
-
'accessing form',
|
|
575
|
-
form.id,
|
|
576
|
-
);
|
|
577
|
-
if (!hasPermission) {
|
|
578
|
-
throw new ForbiddenException('You do not have permission to access this form');
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
```
|
|
582
|
-
|
|
583
|
-
---
|
|
584
|
-
|
|
585
|
-
## Multi-Tenant Support
|
|
586
|
-
|
|
587
|
-
### Configuration
|
|
247
|
+
import { FormBuilderModule } from '@flusys/nestjs-form-builder';
|
|
588
248
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
},
|
|
249
|
+
TypeOrmModule.forRoot({
|
|
250
|
+
entities: [
|
|
251
|
+
...FormBuilderModule.getEntities({ enableCompanyFeature: true }),
|
|
252
|
+
],
|
|
594
253
|
})
|
|
595
254
|
```
|
|
596
255
|
|
|
597
|
-
### Entity Selection
|
|
598
|
-
|
|
599
|
-
The module automatically selects the correct entity:
|
|
600
|
-
- `enableCompanyFeature: false` → `Form` + `FormResult`
|
|
601
|
-
- `enableCompanyFeature: true` → `FormWithCompany` + `FormResult`
|
|
602
|
-
|
|
603
|
-
### Company Filtering
|
|
604
|
-
|
|
605
|
-
When company feature is enabled:
|
|
606
|
-
- Forms are filtered by `user.companyId`
|
|
607
|
-
- Results are filtered via JOIN to Form's `companyId`
|
|
608
|
-
- New forms get `companyId` from user context or DTO
|
|
609
|
-
|
|
610
|
-
### DataSource Provider
|
|
611
|
-
|
|
612
|
-
`FormBuilderDataSourceProvider` extends `MultiTenantDataSourceService`:
|
|
613
|
-
- Maintains separate static cache from other modules
|
|
614
|
-
- Dynamically loads correct entities per tenant
|
|
615
|
-
- Supports per-tenant feature flags
|
|
616
|
-
|
|
617
256
|
---
|
|
618
257
|
|
|
619
|
-
##
|
|
620
|
-
|
|
621
|
-
### Module Options
|
|
622
|
-
|
|
623
|
-
```typescript
|
|
624
|
-
interface FormBuilderModuleOptions extends IDynamicModuleConfig {
|
|
625
|
-
global?: boolean; // Make module global
|
|
626
|
-
includeController?: boolean; // Include REST controllers
|
|
627
|
-
bootstrapAppConfig?: IBootstrapAppConfig; // Bootstrap configuration
|
|
628
|
-
config?: IFormBuilderConfig; // Form builder configuration
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
interface IFormBuilderConfig extends IDataSourceServiceOptions {
|
|
632
|
-
// Currently no form-builder specific runtime config
|
|
633
|
-
// Add form-builder specific settings here as needed
|
|
634
|
-
defaultDatabaseConfig?: IDatabaseConfig;
|
|
635
|
-
tenantDefaultDatabaseConfig?: IDatabaseConfig;
|
|
636
|
-
tenants?: ITenantDatabaseConfig[];
|
|
637
|
-
}
|
|
638
|
-
```
|
|
639
|
-
|
|
640
|
-
### Async Options
|
|
641
|
-
|
|
642
|
-
```typescript
|
|
643
|
-
interface FormBuilderModuleAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
|
|
644
|
-
global?: boolean;
|
|
645
|
-
includeController?: boolean;
|
|
646
|
-
bootstrapAppConfig?: IBootstrapAppConfig;
|
|
647
|
-
useFactory?: (...args: any[]) => Promise<IFormBuilderConfig> | IFormBuilderConfig;
|
|
648
|
-
inject?: any[];
|
|
649
|
-
useExisting?: Type<FormBuilderOptionsFactory>;
|
|
650
|
-
useClass?: Type<FormBuilderOptionsFactory>;
|
|
651
|
-
}
|
|
258
|
+
## Access Control
|
|
652
259
|
|
|
653
|
-
|
|
654
|
-
createFormBuilderOptions(): Promise<IFormBuilderConfig> | IFormBuilderConfig;
|
|
655
|
-
}
|
|
656
|
-
```
|
|
260
|
+
Each form has an `accessType` field that controls who can access it:
|
|
657
261
|
|
|
658
|
-
|
|
262
|
+
| Access Type | Description |
|
|
263
|
+
|-------------|-------------|
|
|
264
|
+
| `PUBLIC` | No authentication required. Anyone can view and submit. |
|
|
265
|
+
| `AUTHENTICATED` | Requires valid JWT token. Any logged-in user can submit. |
|
|
266
|
+
| `ACTION_GROUP` | Requires a specific IAM action permission (from `nestjs-iam`). |
|
|
659
267
|
|
|
660
|
-
|
|
661
|
-
interface IForm {
|
|
662
|
-
id: string;
|
|
663
|
-
name: string;
|
|
664
|
-
description: string | null;
|
|
665
|
-
slug: string | null;
|
|
666
|
-
schema: Record<string, unknown>;
|
|
667
|
-
schemaVersion: number;
|
|
668
|
-
accessType: FormAccessType;
|
|
669
|
-
actionGroups: string[] | null;
|
|
670
|
-
isActive: boolean;
|
|
671
|
-
companyId: string | null;
|
|
672
|
-
metadata: Record<string, unknown> | null;
|
|
673
|
-
createdAt: Date;
|
|
674
|
-
updatedAt: Date;
|
|
675
|
-
deletedAt: Date | null;
|
|
676
|
-
createdById: string | null;
|
|
677
|
-
updatedById: string | null;
|
|
678
|
-
deletedById: string | null;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
interface IFormResult {
|
|
682
|
-
id: string;
|
|
683
|
-
formId: string;
|
|
684
|
-
schemaVersionSnapshot: Record<string, unknown>;
|
|
685
|
-
schemaVersion: number;
|
|
686
|
-
data: Record<string, unknown>;
|
|
687
|
-
submittedById: string | null;
|
|
688
|
-
submittedAt: Date;
|
|
689
|
-
isDraft: boolean;
|
|
690
|
-
metadata: Record<string, unknown> | null;
|
|
691
|
-
// ... audit fields
|
|
692
|
-
}
|
|
268
|
+
**Creating a form with access control:**
|
|
693
269
|
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
270
|
+
```json
|
|
271
|
+
POST /form-builder/form/insert
|
|
272
|
+
{
|
|
273
|
+
"name": "Employee Survey",
|
|
274
|
+
"slug": "employee-survey",
|
|
275
|
+
"accessType": "AUTHENTICATED",
|
|
276
|
+
"schema": { /* JSON schema */ },
|
|
277
|
+
"isPublished": true
|
|
700
278
|
}
|
|
701
279
|
```
|
|
702
280
|
|
|
703
|
-
|
|
281
|
+
**ACTION_GROUP form:**
|
|
704
282
|
|
|
705
|
-
```
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
ACTION_GROUP
|
|
283
|
+
```json
|
|
284
|
+
{
|
|
285
|
+
"name": "Finance Report",
|
|
286
|
+
"slug": "finance-report",
|
|
287
|
+
"accessType": "ACTION_GROUP",
|
|
288
|
+
"requiredAction": "finance.submit-report",
|
|
289
|
+
"schema": { /* JSON schema */ }
|
|
710
290
|
}
|
|
711
291
|
```
|
|
712
292
|
|
|
713
293
|
---
|
|
714
294
|
|
|
715
|
-
##
|
|
716
|
-
|
|
717
|
-
Computed fields are values automatically calculated from form responses when a form is submitted. The calculation happens server-side before storing the result.
|
|
718
|
-
|
|
719
|
-
### How It Works
|
|
295
|
+
## Schema Versioning
|
|
720
296
|
|
|
721
|
-
|
|
722
|
-
2. On final submission (not drafts), backend calculates values
|
|
723
|
-
3. Computed values are stored in `data._computed` namespace
|
|
724
|
-
4. Original field values remain unchanged
|
|
297
|
+
When you update a form's `schema` field, the `schemaVersion` counter is automatically incremented. Every form submission stores the `schemaVersion` at the time of submission.
|
|
725
298
|
|
|
726
|
-
|
|
299
|
+
This allows:
|
|
300
|
+
- Viewing historical submissions against the exact schema that was active
|
|
301
|
+
- Running analytics on form data across schema versions
|
|
302
|
+
- Preventing schema changes from breaking existing submission records
|
|
727
303
|
|
|
728
|
-
```
|
|
729
|
-
|
|
304
|
+
```json
|
|
305
|
+
// Version 1 schema
|
|
306
|
+
{ "fields": [{ "name": "firstName", "type": "text" }] }
|
|
730
307
|
|
|
731
|
-
//
|
|
732
|
-
|
|
733
|
-
// Result: { total_score: 15, category: 'premium' }
|
|
308
|
+
// After update (version 2) - new field added
|
|
309
|
+
{ "fields": [{ "name": "firstName", "type": "text" }, { "name": "lastName", "type": "text" }] }
|
|
734
310
|
```
|
|
735
311
|
|
|
736
|
-
|
|
312
|
+
Historical submissions still reference `schemaVersion: 1` so you know they were submitted before the `lastName` field existed.
|
|
737
313
|
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
```typescript
|
|
741
|
-
interface IComputedField {
|
|
742
|
-
id: string;
|
|
743
|
-
name: string;
|
|
744
|
-
key: string; // Storage key in _computed
|
|
745
|
-
valueType: 'string' | 'number';
|
|
746
|
-
rules: IComputedRule[];
|
|
747
|
-
defaultValue?: string | number | null;
|
|
748
|
-
description?: string;
|
|
749
|
-
}
|
|
314
|
+
---
|
|
750
315
|
|
|
751
|
-
|
|
752
|
-
id: string;
|
|
753
|
-
condition?: IComputedConditionGroup; // Optional - no condition = always apply
|
|
754
|
-
computation: IComputation;
|
|
755
|
-
}
|
|
316
|
+
## Draft Support
|
|
756
317
|
|
|
757
|
-
|
|
758
|
-
operator: 'AND' | 'OR';
|
|
759
|
-
conditions: IComputedCondition[];
|
|
760
|
-
}
|
|
318
|
+
Users can save partial form data as a draft and finalize later:
|
|
761
319
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
320
|
+
```json
|
|
321
|
+
// Save draft (no validation on all required fields)
|
|
322
|
+
POST /form-builder/result/save-draft
|
|
323
|
+
{
|
|
324
|
+
"formId": "uuid",
|
|
325
|
+
"data": { "firstName": "John" }
|
|
766
326
|
}
|
|
767
327
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
328
|
+
// Finalize (validates all required fields)
|
|
329
|
+
POST /form-builder/result/finalize
|
|
330
|
+
{
|
|
331
|
+
"draftId": "uuid",
|
|
332
|
+
"data": { "firstName": "John", "lastName": "Doe" }
|
|
771
333
|
}
|
|
772
|
-
|
|
773
|
-
type ComputationType = 'direct' | 'field_reference' | 'arithmetic';
|
|
774
334
|
```
|
|
775
335
|
|
|
776
|
-
|
|
336
|
+
Draft submissions have `isDraft: true`. Finalized submissions have `isDraft: false`. Users can retrieve their own drafts via `POST /form-builder/result/get-my-results`.
|
|
777
337
|
|
|
778
|
-
|
|
779
|
-
// Direct value - set a static value
|
|
780
|
-
interface IDirectValueConfig {
|
|
781
|
-
type: 'direct';
|
|
782
|
-
value: string | number;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// Field reference - copy value from another field
|
|
786
|
-
interface IFieldReferenceConfig {
|
|
787
|
-
type: 'field_reference';
|
|
788
|
-
fieldId: string;
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
// Arithmetic - calculate from multiple operands
|
|
792
|
-
interface IArithmeticConfig {
|
|
793
|
-
type: 'arithmetic';
|
|
794
|
-
operation: ArithmeticOperation;
|
|
795
|
-
operands: IArithmeticOperand[];
|
|
796
|
-
}
|
|
338
|
+
---
|
|
797
339
|
|
|
798
|
-
|
|
799
|
-
type: 'field' | 'constant';
|
|
800
|
-
fieldId?: string; // When type = 'field'
|
|
801
|
-
value?: number; // When type = 'constant'
|
|
802
|
-
}
|
|
803
|
-
```
|
|
340
|
+
## Computed Fields Engine
|
|
804
341
|
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
| Type | Description |
|
|
808
|
-
|------|-------------|
|
|
809
|
-
| `direct` | Set a static value |
|
|
810
|
-
| `field_reference` | Copy value from another field |
|
|
811
|
-
| `arithmetic` | Calculate using arithmetic operations |
|
|
812
|
-
|
|
813
|
-
### Arithmetic Operations
|
|
814
|
-
|
|
815
|
-
| Operation | Description |
|
|
816
|
-
|-----------|-------------|
|
|
817
|
-
| `sum` | Add all operand values |
|
|
818
|
-
| `subtract` | Subtract subsequent values from first |
|
|
819
|
-
| `multiply` | Multiply all operand values |
|
|
820
|
-
| `divide` | Divide first value by subsequent values |
|
|
821
|
-
| `average` | Calculate average of all operands |
|
|
822
|
-
| `min` | Get minimum value |
|
|
823
|
-
| `max` | Get maximum value |
|
|
824
|
-
| `increment` | Alias for sum |
|
|
825
|
-
| `decrement` | Alias for subtract |
|
|
826
|
-
|
|
827
|
-
### Condition Operators
|
|
828
|
-
|
|
829
|
-
Computed fields support conditional rules with these comparison operators:
|
|
830
|
-
|
|
831
|
-
| Operator | Aliases | Description |
|
|
832
|
-
|----------|---------|-------------|
|
|
833
|
-
| `equals` | | Value equality (string-safe) |
|
|
834
|
-
| `not_equals` | | Value inequality |
|
|
835
|
-
| `contains` | | String contains substring |
|
|
836
|
-
| `not_contains` | | String does not contain substring |
|
|
837
|
-
| `starts_with` | | String starts with value |
|
|
838
|
-
| `ends_with` | | String ends with value |
|
|
839
|
-
| `greater_than` | | Numeric greater than |
|
|
840
|
-
| `less_than` | | Numeric less than |
|
|
841
|
-
| `greater_or_equal` | | Numeric greater or equal |
|
|
842
|
-
| `less_or_equal` | | Numeric less or equal |
|
|
843
|
-
| `is_empty` | | Null, undefined, empty string, or empty array |
|
|
844
|
-
| `is_not_empty` | | Has a non-empty value |
|
|
845
|
-
| `is_before` | | Date comparison (before) |
|
|
846
|
-
| `is_after` | | Date comparison (after) |
|
|
847
|
-
| `is_checked` | | Boolean true (or 'true', or 1) |
|
|
848
|
-
| `is_not_checked` | | Boolean false (or 'false', 0, falsy) |
|
|
849
|
-
| `is_any_of` | `in` | Value is in array |
|
|
850
|
-
| `is_none_of` | `not_in` | Value is not in array |
|
|
851
|
-
|
|
852
|
-
### Data Storage
|
|
853
|
-
|
|
854
|
-
Submission data includes computed values:
|
|
342
|
+
Server-side computed fields are calculated from submission data using declarative rules:
|
|
855
343
|
|
|
856
344
|
```json
|
|
345
|
+
POST /form-builder/form/insert
|
|
857
346
|
{
|
|
858
|
-
"
|
|
859
|
-
"
|
|
860
|
-
"
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
"
|
|
864
|
-
|
|
865
|
-
|
|
347
|
+
"name": "Loan Calculator",
|
|
348
|
+
"schema": {
|
|
349
|
+
"fields": [
|
|
350
|
+
{ "name": "principal", "type": "number", "label": "Loan Amount" },
|
|
351
|
+
{ "name": "rate", "type": "number", "label": "Interest Rate (%)" },
|
|
352
|
+
{ "name": "years", "type": "number", "label": "Term (Years)" }
|
|
353
|
+
],
|
|
354
|
+
"computedFields": [
|
|
355
|
+
{
|
|
356
|
+
"name": "monthlyPayment",
|
|
357
|
+
"label": "Monthly Payment",
|
|
358
|
+
"expression": "(principal * (rate / 1200)) / (1 - Math.pow(1 + rate / 1200, -years * 12))",
|
|
359
|
+
"dependsOn": ["principal", "rate", "years"]
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
"name": "totalPayment",
|
|
363
|
+
"label": "Total Payment",
|
|
364
|
+
"expression": "monthlyPayment * years * 12",
|
|
365
|
+
"dependsOn": ["monthlyPayment", "years"]
|
|
366
|
+
}
|
|
367
|
+
]
|
|
866
368
|
}
|
|
867
369
|
}
|
|
868
370
|
```
|
|
869
371
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
The `FormResultService` automatically calculates computed fields on submission via a private helper:
|
|
873
|
-
|
|
874
|
-
```typescript
|
|
875
|
-
// Private method in FormResultService
|
|
876
|
-
private applyComputedFields(
|
|
877
|
-
data: Record<string, unknown>,
|
|
878
|
-
form: Form,
|
|
879
|
-
isDraft: boolean,
|
|
880
|
-
): Record<string, unknown> {
|
|
881
|
-
if (isDraft) return data;
|
|
882
|
-
|
|
883
|
-
const schema = form.schema as Record<string, unknown>;
|
|
884
|
-
const settings = schema?.settings as Record<string, unknown> | undefined;
|
|
885
|
-
const computedFields = settings?.computedFields as IComputedField[] | undefined;
|
|
886
|
-
|
|
887
|
-
if (!computedFields || computedFields.length === 0) return data;
|
|
888
|
-
|
|
889
|
-
const computedValues = calculateComputedFields(data, computedFields);
|
|
890
|
-
return { ...data, _computed: computedValues };
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// Used in submitForm() and updateDraft()
|
|
894
|
-
const finalData = this.applyComputedFields(dto.data, form, isDraft);
|
|
895
|
-
```
|
|
372
|
+
Computed values are evaluated server-side at submission time and stored with the result.
|
|
896
373
|
|
|
897
374
|
---
|
|
898
375
|
|
|
899
|
-
##
|
|
376
|
+
## Exported Services
|
|
900
377
|
|
|
901
|
-
|
|
378
|
+
| Service | Description |
|
|
379
|
+
|---------|-------------|
|
|
380
|
+
| `FormService` | Form CRUD, slug lookup, publish/unpublish |
|
|
381
|
+
| `FormResultService` | Submission CRUD, draft management, computed field evaluation |
|
|
382
|
+
| `FormBuilderConfigService` | Exposes runtime config and feature flags |
|
|
383
|
+
| `FormBuilderDataSourceProvider` | Dynamic DataSource resolution per request |
|
|
902
384
|
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
| Mode | Description |
|
|
906
|
-
|------|-------------|
|
|
907
|
-
| `multiple` | Default. Users can submit unlimited responses |
|
|
908
|
-
| `single` | Each user can only submit once |
|
|
385
|
+
---
|
|
909
386
|
|
|
910
|
-
|
|
387
|
+
## Programmatic Usage
|
|
911
388
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
| `authenticated` | Server-side via `submittedById` | Reliable |
|
|
915
|
-
| `action_group` | Server-side via `submittedById` | Reliable |
|
|
916
|
-
| `public` | Client-side (frontend handles) | Best-effort only |
|
|
389
|
+
```typescript
|
|
390
|
+
import { FormService, FormResultService } from '@flusys/nestjs-form-builder';
|
|
917
391
|
|
|
918
|
-
|
|
392
|
+
@Injectable()
|
|
393
|
+
export class SurveyService {
|
|
394
|
+
constructor(
|
|
395
|
+
@Inject(FormService) private readonly formService: FormService,
|
|
396
|
+
@Inject(FormResultService) private readonly resultService: FormResultService,
|
|
397
|
+
) {}
|
|
398
|
+
|
|
399
|
+
async getPublicForm(slug: string) {
|
|
400
|
+
return this.formService.getBySlug(slug);
|
|
401
|
+
}
|
|
919
402
|
|
|
920
|
-
|
|
403
|
+
async submitForm(formId: string, data: Record<string, any>, userId: string) {
|
|
404
|
+
return this.resultService.submit({ formId, data, userId });
|
|
405
|
+
}
|
|
921
406
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
@UseGuards(JwtAuthGuard)
|
|
926
|
-
async hasUserSubmitted(
|
|
927
|
-
@Body() dto: GetMyDraftDto, // { formId: string }
|
|
928
|
-
@CurrentUser() user: ILoggedUserInfo,
|
|
929
|
-
): Promise<boolean> {
|
|
930
|
-
return this.formResultService.hasUserSubmitted(dto.formId, user);
|
|
407
|
+
async getFormResults(formId: string) {
|
|
408
|
+
return this.resultService.getAll({ filter: { formId } }, null);
|
|
409
|
+
}
|
|
931
410
|
}
|
|
932
411
|
```
|
|
933
412
|
|
|
934
|
-
**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.
|
|
935
|
-
|
|
936
413
|
---
|
|
937
414
|
|
|
938
|
-
##
|
|
939
|
-
|
|
940
|
-
### Schema Design
|
|
941
|
-
|
|
942
|
-
- Store complete form schema including sections, fields, and settings
|
|
943
|
-
- Use schema versioning to track changes
|
|
944
|
-
- Store schema snapshots with results for historical accuracy
|
|
945
|
-
|
|
946
|
-
### Access Control
|
|
947
|
-
|
|
948
|
-
- Use `public` sparingly - only for truly anonymous forms
|
|
949
|
-
- Prefer `authenticated` for most internal forms
|
|
950
|
-
- Use `action_group` for sensitive forms requiring specific permissions
|
|
951
|
-
|
|
952
|
-
### Company Isolation
|
|
953
|
-
|
|
954
|
-
- Always set `companyId` when company feature is enabled
|
|
955
|
-
- Use user's company context as default
|
|
956
|
-
- Allow explicit `companyId` in DTO for admin operations
|
|
415
|
+
## Troubleshooting
|
|
957
416
|
|
|
958
|
-
|
|
417
|
+
**`Form not found` for a public form**
|
|
959
418
|
|
|
960
|
-
|
|
961
|
-
- Select only needed fields in queries
|
|
962
|
-
- Consider caching frequently accessed forms
|
|
419
|
+
Check that the form is published (`isPublished: true`). Unpublished forms are not returned by the public API.
|
|
963
420
|
|
|
964
421
|
---
|
|
965
422
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
The package includes a Swagger configuration helper that adapts documentation based on feature flags:
|
|
969
|
-
|
|
970
|
-
```typescript
|
|
971
|
-
import { formBuilderSwaggerConfig } from '@flusys/nestjs-form-builder';
|
|
972
|
-
import { setupSwaggerDocs } from '@flusys/nestjs-core/docs';
|
|
973
|
-
|
|
974
|
-
// In bootstrap
|
|
975
|
-
const bootstrapConfig = { enableCompanyFeature: true };
|
|
976
|
-
setupSwaggerDocs(app, formBuilderSwaggerConfig(bootstrapConfig));
|
|
977
|
-
```
|
|
978
|
-
|
|
979
|
-
**Features:**
|
|
980
|
-
- Automatically excludes `companyId` fields when company feature is disabled
|
|
981
|
-
- Generates comprehensive API documentation
|
|
982
|
-
- Documents all access types and workflows
|
|
983
|
-
|
|
984
|
-
### Schema Exclusions
|
|
423
|
+
**Draft finalization fails validation**
|
|
985
424
|
|
|
986
|
-
|
|
987
|
-
- `CreateFormDto`
|
|
988
|
-
- `UpdateFormDto`
|
|
989
|
-
- `FormQueryDto`
|
|
990
|
-
- `FormResponseDto`
|
|
425
|
+
The `finalize` endpoint applies full schema validation. Ensure all required fields in the form schema are present in the submitted data.
|
|
991
426
|
|
|
992
427
|
---
|
|
993
428
|
|
|
994
|
-
|
|
429
|
+
**Computed fields not appearing in submission**
|
|
995
430
|
|
|
996
|
-
|
|
431
|
+
Computed fields are evaluated at submission time. Check that `dependsOn` field names exactly match the form field `name` values (case-sensitive).
|
|
997
432
|
|
|
998
|
-
|
|
999
|
-
import { validateUserPermissions } from '@flusys/nestjs-form-builder';
|
|
1000
|
-
|
|
1001
|
-
// Validate user has at least one of the required permissions
|
|
1002
|
-
const hasPermission = await validateUserPermissions(
|
|
1003
|
-
user, // ILoggedUserInfo
|
|
1004
|
-
['hr.survey.submit', 'admin'], // Required permissions (OR logic)
|
|
1005
|
-
cacheManager, // HybridCache instance
|
|
1006
|
-
enableCompanyFeature, // boolean
|
|
1007
|
-
logger, // Logger instance
|
|
1008
|
-
'submitting form', // Context for audit logging
|
|
1009
|
-
formId, // Resource ID for audit logging
|
|
1010
|
-
);
|
|
1011
|
-
```
|
|
1012
|
-
|
|
1013
|
-
**Features:**
|
|
1014
|
-
- Reads permissions from cache (same format as PermissionGuard)
|
|
1015
|
-
- Fail-closed behavior: cache errors result in access denial
|
|
1016
|
-
- Audit logging for permission denials
|
|
433
|
+
---
|
|
1017
434
|
|
|
1018
|
-
|
|
435
|
+
**`No metadata for entity`**
|
|
1019
436
|
|
|
437
|
+
Register entities in your `TypeOrmModule`:
|
|
1020
438
|
```typescript
|
|
1021
|
-
|
|
1022
|
-
`permissions:company:${companyId}:branch:${branchId}:user:${userId}`
|
|
1023
|
-
|
|
1024
|
-
// Without company feature
|
|
1025
|
-
`permissions:user:${userId}`
|
|
439
|
+
entities: [...FormBuilderModule.getEntities({ enableCompanyFeature: false })]
|
|
1026
440
|
```
|
|
1027
441
|
|
|
1028
442
|
---
|
|
1029
443
|
|
|
1030
|
-
##
|
|
1031
|
-
|
|
1032
|
-
Both controllers use `createApiController` with permission-based security:
|
|
1033
|
-
|
|
1034
|
-
### Form Permissions
|
|
1035
|
-
|
|
1036
|
-
| Operation | Permission |
|
|
1037
|
-
|-----------|------------|
|
|
1038
|
-
| Create | `FORM_PERMISSIONS.CREATE` |
|
|
1039
|
-
| Read | `FORM_PERMISSIONS.READ` |
|
|
1040
|
-
| Update | `FORM_PERMISSIONS.UPDATE` |
|
|
1041
|
-
| Delete | `FORM_PERMISSIONS.DELETE` |
|
|
1042
|
-
|
|
1043
|
-
### Form Result Permissions
|
|
1044
|
-
|
|
1045
|
-
| Operation | Permission |
|
|
1046
|
-
|-----------|------------|
|
|
1047
|
-
| Create | `FORM_RESULT_PERMISSIONS.CREATE` |
|
|
1048
|
-
| Read | `FORM_RESULT_PERMISSIONS.READ` |
|
|
1049
|
-
| Update | `FORM_RESULT_PERMISSIONS.UPDATE` |
|
|
1050
|
-
| Delete | `FORM_RESULT_PERMISSIONS.DELETE` |
|
|
1051
|
-
|
|
1052
|
-
**Note:** Submit endpoints (`submit`, `submit-public`) don't require these permissions - they use the form's `accessType` for authorization.
|
|
1053
|
-
|
|
1054
|
-
---
|
|
1055
|
-
|
|
1056
|
-
## See Also
|
|
444
|
+
## License
|
|
1057
445
|
|
|
1058
|
-
|
|
1059
|
-
- [Shared Guide](SHARED-GUIDE.md) - Base classes and utilities
|
|
1060
|
-
- [Auth Guide](AUTH-GUIDE.md) - User and company management
|
|
446
|
+
MIT © FLUSYS
|
|
1061
447
|
|
|
1062
448
|
---
|
|
1063
449
|
|
|
1064
|
-
**
|
|
450
|
+
> Part of the **FLUSYS** framework — a full-stack monorepo powering Angular 21 + NestJS 11 applications.
|