@flusys/nestjs-form-builder 9.1.2 → 9.2.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 CHANGED
@@ -204,6 +204,16 @@ FormBuilderModule.forRoot({
204
204
  | `POST form-builder/result/submit-public` | `OptionalJwtGuard` | Now also accepts `EMAIL_VERIFIED` forms — pass `{ accessToken }` for anonymous visitors, nothing for JWT-authenticated ones |
205
205
  | `POST form-builder/result/has-submitted-email` | `OptionalJwtGuard` | Anonymous-path counterpart to `has-submitted`, resolving identity the same way |
206
206
 
207
+ **Single-response race:** the "already submitted?" count and the insert are not atomic, so a non-draft submission to a `single` form `claim`s `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.
208
+
209
+ ### Caching
210
+
211
+ `FormService` and `FormResultService` cache `get-all` / `get/:id` (ApiService version stamps, see [nestjs-shared section 7](../nestjs-shared/README.md#7-hybrid-cache)):
212
+
213
+ - 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.
214
+ - With the company feature, cached result lists join `form` for the company filter, so `FormResultService` also depends on the `form` stamp.
215
+ - 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.
216
+
207
217
  ---
208
218
 
209
219
  ## 4. Computed Fields
@@ -7,6 +7,7 @@ declare const FormResultController_base: abstract new (service: FormResultServic
7
7
  readonly enabledEndpoints: import("@flusys/nestjs-shared").ApiEndpoint[] | "all";
8
8
  service: FormResultService;
9
9
  isEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): boolean;
10
+ assertEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): void;
10
11
  insert(addDto: CreateFormResultDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<FormResultResponseDto>>;
11
12
  insertMany(addDto: CreateFormResultDto[], user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").BulkResponseDto<FormResultResponseDto>>;
12
13
  getById(id: string, body: import("@flusys/nestjs-shared").GetByIdBodyDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<FormResultResponseDto>>;
@@ -7,6 +7,7 @@ declare const FormController_base: abstract new (service: FormService) => {
7
7
  readonly enabledEndpoints: import("@flusys/nestjs-shared").ApiEndpoint[] | "all";
8
8
  service: FormService;
9
9
  isEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): boolean;
10
+ assertEnabled(endpoint: import("@flusys/nestjs-shared").ApiEndpoint): void;
10
11
  insert(addDto: CreateFormDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<FormResponseDto>>;
11
12
  insertMany(addDto: CreateFormDto[], user: ILoggedUserInfo | null): Promise<import("@flusys/nestjs-shared").BulkResponseDto<FormResponseDto>>;
12
13
  getById(id: string, body: import("@flusys/nestjs-shared").GetByIdBodyDto, user: ILoggedUserInfo | null): Promise<SingleResponseDto<FormResponseDto>>;
package/fesm/432.js CHANGED
@@ -429,7 +429,7 @@ _ts_decorate([
429
429
  (0,external_typeorm_.Column)({
430
430
  type: 'varchar',
431
431
  length: 50,
432
- default: 'AUTHENTICATED',
432
+ default: form_access_type_enum/* .FormAccessType.AUTHENTICATED */.h.AUTHENTICATED,
433
433
  name: 'access_type'
434
434
  }),
435
435
  _ts_metadata("design:type", typeof form_access_type_enum/* .FormAccessType */.h === "undefined" ? Object : form_access_type_enum/* .FormAccessType */.h)
@@ -1231,15 +1231,21 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1231
1231
  permissionResolver;
1232
1232
  permissionCache;
1233
1233
  formRepository = null;
1234
- // Serializes the single-response check-then-insert per (formId, email) within
1235
- // this process, so a double-click/two-tab submission can't both pass the check
1236
- // before either has saved. Static because the service is REQUEST-scoped - a
1237
- // per-instance lock would never be shared between the two racing requests.
1238
- // Does not span multiple app instances; see withSubmissionLock().
1239
- static submissionLocks = new Map();
1234
+ resultLogger = new _nestjs_common__rspack_import_4.Logger(FormResultService.name);
1235
+ // Bounds how long a crashed instance can block a submitter's retry.
1236
+ static SUBMISSION_CLAIM_TTL_MS = 30_000;
1240
1237
  constructor(cacheManager, utilsService, formBuilderConfig, dataSourceProvider, formEmailAuthService, permissionResolver, permissionCache){
1241
1238
  super(_config_index_js__rspack_import_13/* .FORM_BUILDER_EVENT_ENTITIES.FORM_RESULT */.eF.FORM_RESULT, cacheManager, utilsService, FormResultService.name, true, _config_index_js__rspack_import_13/* .FORM_BUILDER_EVENT_MODULE */.T$, dataSourceProvider, _entities_index_js__rspack_import_7/* .FormResult */.ez), this.cacheManager = cacheManager, this.utilsService = utilsService, this.formBuilderConfig = formBuilderConfig, this.dataSourceProvider = dataSourceProvider, this.formEmailAuthService = formEmailAuthService, this.permissionResolver = permissionResolver, this.permissionCache = permissionCache;
1242
1239
  }
1240
+ // With the company feature, cached lists join the form to filter by its company.
1241
+ cacheDependencies() {
1242
+ return this.formBuilderConfig.isCompanyFeatureEnabled() ? [
1243
+ this.entityName,
1244
+ _config_index_js__rspack_import_13/* .FORM_BUILDER_EVENT_ENTITIES.FORM */.eF.FORM
1245
+ ] : [
1246
+ this.entityName
1247
+ ];
1248
+ }
1243
1249
  async ensureFormRepositoryInitialized() {
1244
1250
  if (!this.formRepository) {
1245
1251
  const enableCompanyFeature = this.formBuilderConfig.isCompanyFeatureEnabled();
@@ -1247,21 +1253,31 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1247
1253
  this.formRepository = await this.dataSourceProvider.getRepository(formEntity);
1248
1254
  }
1249
1255
  }
1250
- async withSubmissionLock(key, fn) {
1251
- const prior = FormResultService.submissionLocks.get(key) ?? Promise.resolve();
1252
- let release;
1253
- const current = new Promise((resolve)=>{
1254
- release = resolve;
1255
- });
1256
- FormResultService.submissionLocks.set(key, current);
1257
- await prior;
1256
+ // Single-response forms: the count check and the insert are not atomic, so a
1257
+ // double-click or two tabs (possibly on different instances) could both pass the
1258
+ // check. The cross-instance claim lets only one submission per form + email run;
1259
+ // a concurrent one is rejected as already submitted. Without a reachable cache the
1260
+ // count check alone still applies.
1261
+ async withSubmissionClaim(formId, email, fn) {
1262
+ const tenant = this.cacheTenant();
1263
+ const claimKey = `form-builder:submit:${tenant ? `${tenant}:` : ''}${formId}:${email}`;
1264
+ let claimed = false;
1265
+ try {
1266
+ claimed = await this.cacheManager.claim(claimKey, FormResultService.SUBMISSION_CLAIM_TTL_MS);
1267
+ if (!claimed) {
1268
+ throw new _nestjs_common__rspack_import_4.ConflictException({
1269
+ message: 'You have already submitted a response to this form',
1270
+ messageKey: _config_index_js__rspack_import_14/* .FORM_RESULT_MESSAGES.ALREADY_SUBMITTED */.y.ALREADY_SUBMITTED
1271
+ });
1272
+ }
1273
+ } catch (error) {
1274
+ if (error instanceof _nestjs_common__rspack_import_4.ConflictException) throw error;
1275
+ this.resultLogger.warn(`Submission claim unavailable for form '${formId}': ${error.message}`);
1276
+ }
1258
1277
  try {
1259
1278
  return await fn();
1260
1279
  } finally{
1261
- release();
1262
- if (FormResultService.submissionLocks.get(key) === current) {
1263
- FormResultService.submissionLocks.delete(key);
1264
- }
1280
+ if (claimed) await this.cacheManager.releaseClaim(claimKey).catch(()=>undefined);
1265
1281
  }
1266
1282
  }
1267
1283
  async getActiveForm(formId) {
@@ -1282,11 +1298,14 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1282
1298
  return form;
1283
1299
  }
1284
1300
  applyCompanyFilterToQuery(query, user) {
1285
- if (this.formBuilderConfig.isCompanyFeatureEnabled() && user?.companyId) {
1286
- query.innerJoin('form', 'f', 'f.id = form_result.formId').andWhere('f.company_id = :companyId', {
1287
- companyId: user.companyId
1288
- });
1301
+ if (!this.formBuilderConfig.isCompanyFeatureEnabled() || !user) return;
1302
+ if (!user.companyId) {
1303
+ query.andWhere('1 = 0');
1304
+ return;
1289
1305
  }
1306
+ query.innerJoin('form', 'f', 'f.id = form_result.formId').andWhere('f.company_id = :companyId', {
1307
+ companyId: user.companyId
1308
+ });
1290
1309
  }
1291
1310
  static PUBLICLY_SUBMITTABLE_ACCESS_TYPES = new Set([
1292
1311
  _enums_form_access_type_enum_js__rspack_import_15/* .FormAccessType.PUBLIC */.h.PUBLIC,
@@ -1354,10 +1373,12 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1354
1373
  }
1355
1374
  return email;
1356
1375
  }
1357
- async assertSingleResponseNotExceeded(form, submitterEmail) {
1358
- if (!submitterEmail) return;
1376
+ isSingleResponse(form) {
1359
1377
  const settings = form.schema?.settings;
1360
- if (settings?.responseMode !== 'single') return;
1378
+ return settings?.responseMode === 'single';
1379
+ }
1380
+ async assertSingleResponseNotExceeded(form, submitterEmail) {
1381
+ if (!submitterEmail || !this.isSingleResponse(form)) return;
1361
1382
  const count = await this.repository.count({
1362
1383
  where: {
1363
1384
  formId: form.id,
@@ -1450,11 +1471,8 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1450
1471
  const submitterEmail = await this.resolveEmailVerifiedSubmitter(form, user, dto.accessToken);
1451
1472
  const isDraft = dto.isDraft ?? false;
1452
1473
  const persist = ()=>this.persistSubmission(form, dto, user, submitterEmail, isDraft);
1453
- // Only a non-draft submission is subject to the single-response check, so
1454
- // only that path needs serializing against a concurrent duplicate for the
1455
- // same form + email (see withSubmissionLock()).
1456
- if (!isDraft && submitterEmail) {
1457
- return this.withSubmissionLock(`${form.id}:${submitterEmail}`, persist);
1474
+ if (!isDraft && submitterEmail && this.isSingleResponse(form)) {
1475
+ return this.withSubmissionClaim(form.id, submitterEmail, persist);
1458
1476
  }
1459
1477
  return persist();
1460
1478
  }
@@ -1462,8 +1480,17 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1462
1480
  if (!isDraft) {
1463
1481
  await this.assertSingleResponseNotExceeded(form, submitterEmail);
1464
1482
  }
1465
- const staleResults = [];
1466
- // Handle existing draft for authenticated users
1483
+ let saved;
1484
+ try {
1485
+ saved = await this.writeSubmission(form, dto, user, submitterEmail, isDraft);
1486
+ } finally{
1487
+ // Also after a failed insert: the replaced draft may already be soft-removed.
1488
+ await this.invalidateCache();
1489
+ }
1490
+ await this.publishSubmissionEvent(saved, dto.formId, saved.isDraft, user);
1491
+ return this.convertEntityToResponseDto(saved, false);
1492
+ }
1493
+ async writeSubmission(form, dto, user, submitterEmail, isDraft) {
1467
1494
  if (user?.id) {
1468
1495
  const existingDraft = await this.repository.findOne({
1469
1496
  where: this.buildUserDraftWhere(dto.formId, user.id)
@@ -1476,18 +1503,12 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1476
1503
  schemaVersion: form.schemaVersion,
1477
1504
  submittedAt: new Date()
1478
1505
  });
1479
- const saved = await this.repository.save(existingDraft);
1480
- await this.invalidateResultCache([
1481
- saved
1482
- ]);
1483
- await this.publishSubmissionEvent(saved, dto.formId, true, user);
1484
- return this.convertEntityToResponseDto(saved, false);
1506
+ return await this.repository.save(existingDraft);
1485
1507
  }
1486
1508
  await this.repository.softRemove(existingDraft);
1487
- staleResults.push(existingDraft);
1488
1509
  }
1489
1510
  }
1490
- const saved = await this.repository.save({
1511
+ return await this.repository.save({
1491
1512
  formId: dto.formId,
1492
1513
  schemaVersionSnapshot: form.schema,
1493
1514
  schemaVersion: form.schemaVersion,
@@ -1497,12 +1518,6 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1497
1518
  submittedAt: new Date(),
1498
1519
  isDraft
1499
1520
  });
1500
- await this.invalidateResultCache([
1501
- ...staleResults,
1502
- saved
1503
- ]);
1504
- await this.publishSubmissionEvent(saved, dto.formId, isDraft, user);
1505
- return this.convertEntityToResponseDto(saved, false);
1506
1521
  }
1507
1522
  async getMyDraft(formId, user) {
1508
1523
  await this.ensureDataSourceRepository();
@@ -1519,7 +1534,9 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1519
1534
  const draft = await this.repository.findOne({
1520
1535
  where: {
1521
1536
  id: draftId,
1537
+ formId: dto.formId,
1522
1538
  submittedById: user.id,
1539
+ isDraft: true,
1523
1540
  deletedAt: (0,typeorm__rspack_import_5.IsNull)()
1524
1541
  }
1525
1542
  });
@@ -1539,9 +1556,7 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1539
1556
  isDraft
1540
1557
  });
1541
1558
  const saved = await this.repository.save(draft);
1542
- await this.invalidateResultCache([
1543
- saved
1544
- ]);
1559
+ await this.invalidateCache();
1545
1560
  await this.publishSubmissionEvent(saved, dto.formId, isDraft, user);
1546
1561
  return this.convertEntityToResponseDto(saved, false);
1547
1562
  }
@@ -1559,12 +1574,6 @@ class FormResultService extends _flusys_nestjs_shared_classes__rspack_import_0.A
1559
1574
  user
1560
1575
  });
1561
1576
  }
1562
- async invalidateResultCache(results) {
1563
- await Promise.all([
1564
- this.clearCacheForAll(),
1565
- this.clearCacheForId(results)
1566
- ]);
1567
- }
1568
1577
  async getByFormId(formId, user, pagination) {
1569
1578
  await this.ensureDataSourceRepository();
1570
1579
  const query = this.repository.createQueryBuilder(this.entityName).where('form_result.formId = :formId', {
@@ -1780,6 +1789,9 @@ class FormService extends classes_.ApiService {
1780
1789
  ...form,
1781
1790
  ...dto
1782
1791
  };
1792
+ if (!isUpdate) {
1793
+ form.accessType ??= form_access_type_enum/* .FormAccessType.AUTHENTICATED */.h.AUTHENTICATED;
1794
+ }
1783
1795
  // Set company context if enabled
1784
1796
  const enableCompanyFeature = this.formBuilderConfig.isCompanyFeatureEnabled();
1785
1797
  if (enableCompanyFeature) {
@@ -104,7 +104,7 @@ _ts_decorate([
104
104
  (0,external_typeorm_.Column)({
105
105
  type: 'varchar',
106
106
  length: 50,
107
- default: 'AUTHENTICATED',
107
+ default: form_access_type_enum/* .FormAccessType.AUTHENTICATED */.h.AUTHENTICATED,
108
108
  name: 'access_type'
109
109
  }),
110
110
  _ts_metadata("design:type", typeof form_access_type_enum/* .FormAccessType */.h === "undefined" ? Object : form_access_type_enum/* .FormAccessType */.h)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flusys/nestjs-form-builder",
3
- "version": "9.1.2",
3
+ "version": "9.2.0",
4
4
  "description": "Dynamic form builder module with schema versioning and access control",
5
5
  "type": "module",
6
6
  "main": "./fesm/index.js",
@@ -75,7 +75,7 @@
75
75
  "express": "^5.0.0"
76
76
  },
77
77
  "dependencies": {
78
- "@flusys/nestjs-core": "9.1.2",
79
- "@flusys/nestjs-shared": "9.1.2"
78
+ "@flusys/nestjs-core": "9.2.0",
79
+ "@flusys/nestjs-shared": "9.2.0"
80
80
  }
81
81
  }
@@ -18,15 +18,18 @@ export declare class FormResultService extends ApiService<CreateFormResultDto, U
18
18
  private readonly permissionResolver?;
19
19
  private readonly permissionCache?;
20
20
  private formRepository;
21
- private static readonly submissionLocks;
21
+ private readonly resultLogger;
22
+ private static readonly SUBMISSION_CLAIM_TTL_MS;
22
23
  constructor(cacheManager: HybridCache, utilsService: UtilsService, formBuilderConfig: FormBuilderConfigService, dataSourceProvider: FormBuilderDataSourceProvider, formEmailAuthService: FormEmailAuthService, permissionResolver?: IPermissionResolver | undefined, permissionCache?: SharedPermissionCacheService | undefined);
24
+ protected cacheDependencies(): string[];
23
25
  private ensureFormRepositoryInitialized;
24
- private withSubmissionLock;
26
+ private withSubmissionClaim;
25
27
  private getActiveForm;
26
28
  private applyCompanyFilterToQuery;
27
29
  private static readonly PUBLICLY_SUBMITTABLE_ACCESS_TYPES;
28
30
  private validateSubmissionAccess;
29
31
  private resolveEmailVerifiedSubmitter;
32
+ private isSingleResponse;
30
33
  private assertSingleResponseNotExceeded;
31
34
  private buildUserDraftWhere;
32
35
  getSelectQuery(query: SelectQueryBuilder<FormResult>, _user: ILoggedUserInfo | null, select?: string[]): Promise<{
@@ -41,10 +44,10 @@ export declare class FormResultService extends ApiService<CreateFormResultDto, U
41
44
  protected convertEntityToResponseDto(entity: FormResult, _isRaw: boolean): IFormResult;
42
45
  submitForm(dto: SubmitFormDto, user: ILoggedUserInfo | null, isPublic?: boolean): Promise<IFormResult>;
43
46
  private persistSubmission;
47
+ private writeSubmission;
44
48
  getMyDraft(formId: string, user: ILoggedUserInfo): Promise<IFormResult | null>;
45
49
  updateDraft(draftId: string, dto: SubmitFormDto, user: ILoggedUserInfo): Promise<IFormResult>;
46
50
  private publishSubmissionEvent;
47
- private invalidateResultCache;
48
51
  getByFormId(formId: string, user: ILoggedUserInfo | null, pagination?: {
49
52
  page?: number;
50
53
  pageSize?: number;