@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:
|
|
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
|
-
|
|
1235
|
-
//
|
|
1236
|
-
|
|
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
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
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
|
-
|
|
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()
|
|
1286
|
-
|
|
1287
|
-
|
|
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
|
-
|
|
1358
|
-
if (!submitterEmail) return;
|
|
1376
|
+
isSingleResponse(form) {
|
|
1359
1377
|
const settings = form.schema?.settings;
|
|
1360
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
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
|
-
|
|
1466
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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) {
|
package/fesm/entities/index.js
CHANGED
|
@@ -104,7 +104,7 @@ _ts_decorate([
|
|
|
104
104
|
(0,external_typeorm_.Column)({
|
|
105
105
|
type: 'varchar',
|
|
106
106
|
length: 50,
|
|
107
|
-
default:
|
|
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.
|
|
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.
|
|
79
|
-
"@flusys/nestjs-shared": "9.
|
|
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
|
|
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
|
|
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;
|