• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

formcapture / form-backend / 16905974748

12 Aug 2025 10:20AM UTC coverage: 65.766% (-2.5%) from 68.25%
16905974748

push

github

web-flow
Merge pull request #164 from formcapture/feat/errorhandling

Feat: Improve error handling in app and backend

308 of 556 branches covered (55.4%)

Branch coverage included in aggregate %.

86 of 140 new or added lines in 11 files covered. (61.43%)

19 existing lines in 1 file now uncovered.

812 of 1147 relevant lines covered (70.79%)

6.75 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

50.1
/backend/src/processor/data.ts
1
import { PostgrestClient, PostgrestFilterBuilder, PostgrestResponse } from '@supabase/postgrest-js';
2
import { Logger } from 'winston';
3

4
import { FormBackendErrorCode } from '../errors/FormBackendErrorCode';
4✔
5
import { DatabaseError, GenericRequestError, InternalServerError } from '../errors/GenericRequestError';
4✔
6
import { setupLogger } from '../logger';
4✔
7
import { DataItem } from '../types/data';
8
import { DbAction } from '../types/dbAction';
4✔
9
import { FilterType } from '../types/filter';
10
import { FormConfigInternal } from '../types/formConfigInternal';
11
import { FormConfigProperties } from '../types/formConfigProperties';
12
import { JoinTable } from '../types/joinTable';
13
import { Opts } from '../types/opts';
14
import { Relationship } from '../types/relationship';
4✔
15

16
import FileProcessor from './file';
17
import FormConfigProcessor from './formConfig';
4✔
18

19
export interface DataProcessorOpts {
20
  pgClient: PostgrestClient<any, any, any>;
21
  formId: string;
22
  opts: Opts;
23
}
24

25
class DataProcessor {
26
  #pgClient: PostgrestClient<any, any, any>;
27
  #fileProcessor: FileProcessor;
28
  #logger: Logger;
29

30
  // TODO remove all unnecessary checks as soon as we validate the formConfig
31
  constructor(opts: DataProcessorOpts, fileProcessor: FileProcessor) {
32
    this.#pgClient = opts.pgClient;
26✔
33
    this.#fileProcessor = fileProcessor;
26✔
34
    this.#logger = setupLogger({ label: 'dataProcessor' });
26✔
35
  }
36

37
  replaceEmptyDateFields(data: DataItem, properties: FormConfigProperties): DataItem {
38
    const itemClone = structuredClone(data);
4✔
39

40
    Object.entries(properties)
4✔
41
      .filter(([, value]) => value.format === 'date')
4✔
42
      .forEach(([key]) => {
43
        if (itemClone[key] === '') {
1!
44
          itemClone[key] = null;
1✔
45
        }
46
      });
47

48
    return itemClone;
4✔
49
  }
50

51
  async getFormData(formConfig: FormConfigInternal, page: number = 0, filter: FilterType) {
×
52
    const tableName = formConfig.dataSource.tableName;
×
53
    if (!tableName) {
×
54
      return;
×
55
    }
56

57
    const selectStatement = this.#createFormSelectStatement(formConfig);
×
58

59
    const rangeLower = page * formConfig.views.pageSize;
×
60
    const rangeUpper = rangeLower + formConfig.views.pageSize - 1;
×
61
    // TODO think about how to work with ranges and join tables, many-to-many etc
62

63
    const query = this.#pgClient
×
64
      .schema(formConfig.dataSource.schema || this.#pgClient.schemaName!)
×
65
      .from(tableName)
66
      .select(selectStatement, { count: 'exact' });
67

68
    this.#addOrderQuery(query, formConfig);
×
69
    query.range(rangeLower, rangeUpper);
×
70
    this.#addFilterQuery(query, filter);
×
71

72
    const data = await query;
×
73

74
    // Check if data is falsy or has an error or a non-2xx status code
75
    if (!data || data.error || data.status < 200 || data.status >= 300) {
×
76
      this.#logger.error(data?.error);
×
77
      return;
×
78
    }
79

80
    // Reconstruct to the flat data structure which is expected by the client,
81
    // but was changed to allow for ordering by lookup display value.
82
    const reconstructedData = this.#reconstructTableData(data.data, formConfig);
×
83

84
    return {
×
85
      data: reconstructedData,
86
      count: data.count
87
    };
88
  }
89

90
  async getItemData(itemId: string, formConfig: FormConfigInternal | JoinTable) {
91
    if (itemId === undefined || itemId === null) {
×
92
      this.#logger.info('No item id provided');
×
93
      return;
×
94
    }
95

96
    const tableName = formConfig.dataSource.tableName;
×
97
    if (!tableName) {
×
98
      return;
×
99
    }
100

101
    const idColumn = formConfig.dataSource.idColumn;
×
102
    if (!idColumn) {
×
103
      this.#logger.info('No id column provided');
×
104
      return;
×
105
    }
106

107
    const selectStatement = this.#createItemSelectStatement(formConfig);
×
108

109
    // @ts-ignore
110
    const data: PostgrestResponse<DataItem> = await this.#pgClient
×
111
      .schema(formConfig.dataSource.schema || this.#pgClient.schemaName!)
×
112
      .from(tableName)
113
      .select(selectStatement)
114
      .eq(idColumn, itemId);
115

116
    if (data.error || data.status !== 200) {
×
117
      this.#logger.error(data.error);
×
118
      return;
×
119
    }
120

121
    return {
×
122
      data: data.data
123
    };
124
  }
125

126
  async createItemData(item: DataItem, formConfig: FormConfigInternal | JoinTable, configKey?: string) {
127
    this.#logger.debug(`Creating item for configKey ${configKey}`);
3✔
128

129
    let sanitizedData = this.#sanitizeData(item, formConfig);
3✔
130
    const tableName = formConfig.dataSource.tableName;
3✔
131
    let isSuccess = false;
3✔
132
    if (!tableName) {
3!
133
      return { success: isSuccess };
×
134
    }
135

136
    const keysAndFiles = this.#fileProcessor.getFilesFromItem(sanitizedData, formConfig);
3✔
137
    sanitizedData = this.#fileProcessor.getItemWithoutFiles(sanitizedData, Object.keys(keysAndFiles));
3✔
138

139
    const response = await this.#pgClient
3✔
140
      .schema(formConfig.dataSource.schema || this.#pgClient.schemaName)
6✔
141
      .from(tableName)
142
      .insert(sanitizedData)
143
      .select();
144

145
    if (response.status !== 201) {
3✔
146
      this.#logger.error(
1✔
147
        `Could not create data for item ${JSON.stringify(item)} of table ${tableName}. ${response?.error?.message}`
148
      );
149

150
      return {
1✔
151
        success: isSuccess,
152
        code: response.error?.code,
153
        message: response.error?.message
154
      };
155
    }
156

157
    const createdItem = response.data?.[0] as DataItem;
2✔
158
    if (Object.keys(keysAndFiles).length > 0) {
2✔
159
      const updatedItem = await this.#fileProcessor.createFiles({
1✔
160
        item: createdItem,
161
        keysAndFiles,
162
        formConfig,
163
        configKey
164
      });
165
      const updateResponse = await this.#pgClient
1✔
166
        .schema(formConfig.dataSource.schema || this.#pgClient.schemaName!)
2✔
167
        .from(tableName)
168
        .update(updatedItem)
169
        .eq(formConfig.dataSource.idColumn, createdItem[formConfig.dataSource.idColumn]);
170

171
      if (updateResponse.status !== 204) {
1!
172
        this.#logger.error(
1✔
173
          `Could not update data for item ${JSON.stringify(item)} of table ${tableName}. ${response?.error?.message}`
174
        );
175
        return { success: isSuccess };
1✔
176
      }
177
    }
178

179
    const createdItemId = createdItem[formConfig.dataSource.idColumn];
1✔
180

181
    isSuccess = true;
1✔
182
    return { success: isSuccess, id: createdItemId };
1✔
183
  }
184

185
  async updateItemData(
186
    item: DataItem,
187
    itemId: string,
188
    formConfig: FormConfigInternal | JoinTable,
189
    configKey?: string
190
  ) {
191
    this.#logger.debug(`Updating item ${itemId} for configKey ${configKey}`);
×
192
    let sanitizedData = this.#sanitizeData(item, formConfig);
×
193
    const keysAndFiles = this.#fileProcessor.getFilesFromItem(sanitizedData, formConfig);
×
194
    sanitizedData = await this.#fileProcessor.createFiles({
×
195
      item: sanitizedData,
196
      keysAndFiles,
197
      formConfig,
198
      itemId,
199
      configKey
200
    });
201
    const emptyFileFields = this.#fileProcessor.getEmptyFileFields(sanitizedData, formConfig);
×
202
    await this.#fileProcessor.deleteFiles(itemId, formConfig, emptyFileFields, configKey);
×
203
    const successfullyUpated = await this.#updateData(sanitizedData, itemId, formConfig);
×
204
    return { id: itemId, success: successfullyUpated };
×
205
  }
206

207
  async deleteItemData(itemId: string, formConfig: FormConfigInternal | JoinTable) {
208
    this.#logger.debug(`Deleting item ${itemId}`);
3✔
209
    const tableName = formConfig.dataSource.tableName;
3✔
210
    const idColumn = formConfig.dataSource.idColumn;
3✔
211
    let isSuccess = false;
3✔
212
    if (!tableName || !idColumn) {
3!
213
      return { id: itemId, success: isSuccess };
×
214
    }
215

216
    try {
3✔
217
      await this.#fileProcessor.deleteFiles(itemId, formConfig);
3✔
218
    } catch (err) {
219
      this.#logger.error(`Could not delete files for item ${itemId} of table ${tableName}. ${err}`);
×
220
    }
221

222
    // @ts-ignore
223
    const response = await this.#pgClient
3✔
224
      .schema(formConfig.dataSource.schema || this.#pgClient.schemaName!)
6✔
225
      .from(tableName)
226
      .delete()
227
      .eq(idColumn, itemId);
228

229
    if (response.status !== 204) {
3✔
230
      this.#logger.error(`Could not delete data for item ${itemId} of table ${tableName}. ${response?.error?.message}`);
2✔
231
      return { id: itemId, success: isSuccess };
2✔
232
    }
233
    isSuccess = true;
1✔
234
    return { id: itemId, success: isSuccess };
1✔
235
  }
236

237
  async handleData(
238
    item: DataItem,
239
    formConfig: FormConfigInternal | JoinTable,
240
    action: DbAction,
241
    configKeys?: string[]
242
  ) {
243
    let itemId: string;
244

245
    const propsWithToOne = [
26✔
246
      ...FormConfigProcessor.getPropsWithJoinTables(formConfig, Relationship.MANY_TO_ONE),
247
      ...FormConfigProcessor.getPropsWithJoinTables(formConfig, Relationship.ONE_TO_ONE)
248
    ];
249
    for (const prop of propsWithToOne) {
26✔
250
      if (!item[prop]) {
6!
251
        continue;
×
252
      }
253
      const newConfigKeys = configKeys ? [...configKeys, prop] : [prop];
6!
254
      item[prop] = await this.#handleToOneData(item[prop], formConfig, newConfigKeys);
6✔
255
    }
256

257
    switch (action) {
26!
258
      case DbAction.CREATE: {
259
        const createdItem = await this.createItemData(item, formConfig, configKeys?.join('/'));
20✔
260
        if (!createdItem.success) {
20!
NEW
261
          throw new DatabaseError(`Could not create item. ${createdItem.message}`, {
×
262
            errorCode: FormBackendErrorCode.ITEM_CREATION_FAILED,
263
            detailedMessage: createdItem.message,
264
            dbErrorCode: createdItem.code
265
          });
266
        }
267
        itemId = createdItem.id;
20✔
268
        break;
20✔
269
      }
270
      case DbAction.UPDATE: {
271
        const updatedItem = await this.updateItemData(
5✔
272
          item,
273
          item[formConfig.dataSource.idColumn],
274
          formConfig,
275
          configKeys?.join('/')
276
        );
277
        itemId = updatedItem.id;
5✔
278
        this.#logger.debug(`Updated item ${itemId} for configKeys ${configKeys}`);
5✔
279
        break;
5✔
280
      }
281
      case DbAction.DELETE:
282
        itemId = item[formConfig.dataSource.idColumn];
1✔
283
        break;
1✔
284
      default:
NEW
285
        throw new InternalServerError(`Action ${action} not supported`);
×
286
    }
287

288
    const propsWithManyToMany = FormConfigProcessor.getPropsWithJoinTables(formConfig, Relationship.MANY_TO_MANY);
26✔
289
    for (const prop of propsWithManyToMany) {
26✔
290
      const newConfigKeys = configKeys ? [...configKeys, prop] : [prop];
5!
291
      await this.#handleManyToManyData(itemId, item[prop] ?? [], formConfig, newConfigKeys);
5!
292
    }
293

294
    const propsWithOneToMany = FormConfigProcessor.getPropsWithJoinTables(formConfig, Relationship.ONE_TO_MANY);
26✔
295
    for (const prop of propsWithOneToMany) {
26✔
296
      const newConfigKeys = configKeys ? [...configKeys, prop] : [prop];
3!
297
      await this.#handleOneToManyData(itemId, item[prop] ?? [], formConfig, newConfigKeys);
3!
298
    }
299

300
    // we have to first remove references, so deletions come after handling join tables
301
    if (action === DbAction.DELETE) {
26✔
302
      const deletedItem = await this.deleteItemData(item[formConfig.dataSource.idColumn], formConfig);
1✔
303
      itemId = deletedItem.id;
1✔
304
    }
305

306
    return itemId;
26✔
307
  }
308

309
  async #handleManyToManyData(
310
    itemId: string,
311
    items: DataItem[],
312
    formConfig: FormConfigInternal | JoinTable,
313
    configKeys: string[]
314
  ) {
315
    this.#logger.debug(`Handling many-to-many join table data for configKeys ${configKeys.join('/')}`);
5✔
316
    const configKey = configKeys.at(-1);
5✔
317
    if (!configKey) {
5!
NEW
318
      throw new DatabaseError('Cannot handle manyToMany table. ConfigKeys is empty', {
×
319
        errorCode: FormBackendErrorCode.CONFIG_KEY_IS_EMPTY
320
      });
321
    }
322
    // TODO this can probably be improved to only get the necessary data
323
    const dbJoinItems = (await this.getItemData(itemId, formConfig))?.data[0][configKey];
5✔
324
    if (!dbJoinItems) {
5!
NEW
325
      throw new DatabaseError(`Could not get db items for item ${itemId} and configKey ${configKeys.join('/')}`, {
×
326
        errorCode: FormBackendErrorCode.DB_JOIN_ITEMS_NOT_FOUND_FOR_CONFIG_KEYS
327
      });
328
    }
329

330
    if (JSON.stringify(items) === JSON.stringify(dbJoinItems)) {
5✔
331
      return;
1✔
332
    }
333

334
    const joinTableConfig = formConfig.dataSource.joinTables[configKey];
4✔
335

336
    const addedJoinItems = items
4✔
337
      .filter(item => this.#wasItemAdded(item, dbJoinItems, joinTableConfig))
3✔
338
      .map(item => {
339
        let action: DbAction = DbAction.CREATE;
2✔
340
        if (item[formConfig.dataSource.idColumn]) {
2!
341
          action = DbAction.UPDATE;
×
342
        }
343
        return this.handleData(item, joinTableConfig, action, configKeys);
2✔
344
      });
345
    const createdJoinItems = await Promise.all(addedJoinItems);
4✔
346
    if (addedJoinItems.length > 0) {
4✔
347
      await this.#populateJoinTableData(createdJoinItems, itemId, joinTableConfig);
2✔
348
    }
349

350
    const updatedItems = items
4✔
351
      .filter(item => this.#wasItemUpdated(item, dbJoinItems, joinTableConfig))
3✔
352
      .map(item => this.handleData(item, joinTableConfig, DbAction.UPDATE, configKeys));
1✔
353
    await Promise.all(updatedItems);
4✔
354

355
    // when deleting on manyToMany, we only delete the join table, since the other side might still be used
356
    const removedJoinItems = this.#getRemovedJoinItems(items, dbJoinItems, joinTableConfig);
4✔
357
    if (removedJoinItems.length > 0) {
4✔
358
      await this.#removeFromJoinTable(itemId, removedJoinItems, joinTableConfig);
1✔
359
    }
360

361
    this.#logger.debug(`Successfully handled many-to-many join table data for configKeys ${configKeys.join('/')}`);
4✔
362
  }
363

364
  async #handleOneToManyData(
365
    itemId: string,
366
    items: DataItem[],
367
    formConfig: FormConfigInternal | JoinTable,
368
    configKeys: string[]
369
  ) {
370
    this.#logger.debug(`Handling one-to-many join table data for configKeys ${configKeys.join('/')}`);
3✔
371
    const configKey = configKeys.at(-1);
3✔
372
    if (!configKey) {
3!
NEW
373
      throw new DatabaseError('Cannot handle oneToMany table. ConfigKeys is empty', {
×
374
        errorCode: FormBackendErrorCode.CONFIG_KEY_IS_EMPTY
375
      });
376
    }
377
    const dbJoinItems = (await this.getItemData(itemId, formConfig))?.data[0][configKey];
3✔
378
    if (!dbJoinItems) {
3!
NEW
379
      throw new DatabaseError(`Could not get db items for item ${itemId} and configKey ${configKeys.join('/')}`, {
×
380
        errorCode: FormBackendErrorCode.DB_JOIN_ITEMS_NOT_FOUND_FOR_CONFIG_KEYS
381
      });
382
    }
383

384
    this.#logger.debug(`Received following existing dbJoinItems: ${JSON.stringify(dbJoinItems)}`);
3✔
385

386
    if (JSON.stringify(items) === JSON.stringify(dbJoinItems)) {
3✔
387
      this.#logger.debug(`No changes in one-to-many join table data for configKeys ${configKeys.join('/')}. Skipping`);
1✔
388
      return;
1✔
389
    }
390

391
    const joinTableConfig = formConfig.dataSource.joinTables[configKey];
2✔
392

393
    const addedJoinItems = items
2✔
394
      .filter(item => this.#wasItemAdded(item, dbJoinItems, joinTableConfig))
2✔
395
      .map(item => {
396
        const itemWithFk = {
1✔
397
          ...item,
398
          [joinTableConfig.on.self]: itemId
399
        };
400
        this.#logger.debug(`Adding join table data for item ${itemId}`);
1✔
401
        return this.handleData(itemWithFk, joinTableConfig, DbAction.CREATE, configKeys);
1✔
402
      });
403
    await Promise.all(addedJoinItems);
2✔
404

405
    const updatedItems = items
2✔
406
      .filter(item => this.#wasItemUpdated(item, dbJoinItems, joinTableConfig))
2✔
407
      .map(item => {
408
        const itemWithFk = {
1✔
409
          ...item,
410
          [joinTableConfig.on.self]: itemId
411
        };
412
        this.#logger.debug(`Updating join table data for item ${itemId}`);
1✔
413
        return this.handleData(itemWithFk, joinTableConfig, DbAction.UPDATE, configKeys);
1✔
414
      });
415
    await Promise.all(updatedItems);
2✔
416

417
    // For now, we will only remove the FK from the other side of the relationship.
418
    const removedJoinItems = this.#getRemovedJoinItems(items, dbJoinItems, joinTableConfig)
2✔
419
      .map(item => {
420
        this.#logger.debug(`Removing join table data for item ${itemId}`);
×
421
        return this.#unjoinOneToMany(item, joinTableConfig);
×
422
      });
423
    await Promise.all(removedJoinItems);
2✔
424

425
    this.#logger.debug(`Successfully handled one-to-many join table data for configKeys ${configKeys.join('/')}`);
2✔
426
  }
427

428
  async #handleToOneData(item: DataItem, formConfig: FormConfigInternal | JoinTable, configKeys: string[]) {
429
    this.#logger.debug(`Handling many-to-one join table data for configKeys ${configKeys.join('/')}`);
6✔
430

431
    const configKey = configKeys.at(-1);
6✔
432
    if (!configKey) {
6!
NEW
433
      throw new DatabaseError('Cannot handle oneToMany table. ConfigKeys is empty', {
×
434
        errorCode: FormBackendErrorCode.CONFIG_KEY_IS_EMPTY
435
      });
436
    }
437

438
    const itemId = item[formConfig.dataSource.joinTables[configKey].dataSource.idColumn];
6✔
439
    const dbJoinItem = (await this.getItemData(itemId, formConfig.dataSource.joinTables[configKey]))?.data[0];
6✔
440

441
    if (JSON.stringify(item) === JSON.stringify(dbJoinItem)) {
6✔
442
      return itemId;
2✔
443
    }
444

445
    let action: DbAction = DbAction.CREATE;
4✔
446
    if (dbJoinItem) {
4✔
447
      action = DbAction.UPDATE;
2✔
448
    }
449

450
    const handledItemId = await this.handleData(item, formConfig.dataSource.joinTables[configKey], action, configKeys);
4✔
451
    this.#logger.debug(`Successfully handled many-to-one join table data for configKeys ${configKeys.join('/')}`);
4✔
452

453
    return handledItemId;
4✔
454
  }
455

456
  async #unjoinOneToMany(item: DataItem, formConfig: FormConfigInternal | JoinTable) {
457
    const fkColumn = formConfig.on.self;
×
458
    const tableName = formConfig.dataSource.tableName;
×
459
    const idColumn = formConfig.dataSource.idColumn;
×
460
    this.#logger.debug(`Unjoining data for item ${item[idColumn]} of table ${tableName}`);
×
461

462
    const unjoinResponse = await this.#pgClient
×
463
      .schema(formConfig.dataSource.schema || this.#pgClient.schemaName!)
×
464
      .from(formConfig.dataSource.tableName)
465
      .update({ [fkColumn]: null })
466
      .eq(idColumn, item[idColumn]);
467

468
    if (unjoinResponse.status !== 204) {
×
469
      this.#logger.error(
×
470
        `Could not unjoin data for item ${item[idColumn]} of table ${tableName}. ${unjoinResponse?.error?.message}`
471
      );
472
    }
473
    this.#logger.debug(`Successfully unjoined data for item ${item[idColumn]} of table ${tableName}`);
×
474
  }
475

476
  #wasItemAdded(item: DataItem, unchangedDbItems: DataItem[], formConfig: FormConfigInternal | JoinTable) {
477
    this.#logger.debug(`Checking if item ${JSON.stringify(item)} was added`);
5✔
478
    const idColumn = formConfig.dataSource.idColumn;
5✔
479
    const wasAdded = !unchangedDbItems.some(unchangedDbItem => {
5✔
480
      return unchangedDbItem[idColumn] === item[idColumn];
2✔
481
    });
482
    this.#logger.debug(`Item ${JSON.stringify(item)} was ${wasAdded ? '' : 'not '}added`);
5✔
483
    return wasAdded;
5✔
484
  }
485

486
  #wasItemUpdated(item: DataItem, unchangedDbItems: DataItem[], formConfig: FormConfigInternal | JoinTable) {
487
    this.#logger.debug(`Checking if item ${JSON.stringify(item)} was updated`);
5✔
488
    const idColumn = formConfig.dataSource.idColumn;
5✔
489
    const wasUpdated = unchangedDbItems.some(
5✔
490
      unchangedDbItem => unchangedDbItem[idColumn] === item[idColumn]
2✔
491
        && JSON.stringify(unchangedDbItem) !== JSON.stringify(item)
492
    );
493
    this.#logger.debug(`Item ${JSON.stringify(item)} was ${wasUpdated ? '' : 'not '}updated`);
5✔
494
    return wasUpdated;
5✔
495
  }
496

497
  #getRemovedJoinItems(items: DataItem[], unchangedDbItems: DataItem[], formConfig: FormConfigInternal | JoinTable) {
498
    const idColumn = formConfig.dataSource.idColumn;
6✔
499
    return unchangedDbItems
6✔
500
      .filter(unchangedDbItem => !items.some(item => unchangedDbItem[idColumn] === item[idColumn]));
3✔
501
  }
502

503
  async #populateJoinTableData(
504
    createdJoinItems: string[],
505
    itemId: string | number,
506
    formConfig: FormConfigInternal | JoinTable
507
  ) {
508
    if (formConfig.relationship === 'manyToMany') {
2!
509
      const joinTableData = createdJoinItems.map(id => ({
2✔
510
        [formConfig.on.self]: itemId,
511
        [formConfig.on.other]: id
512
      }));
513

514
      this.#logger.debug(`Inserting join table data for item ${itemId}`);
2✔
515
      const updateJoinTableResponse = await this.#pgClient
2✔
516
        .schema(formConfig.via.schema || this.#pgClient.schemaName!)
4✔
517
        .from(formConfig.via.tableName)
518
        .insert(joinTableData);
519

520
      if (updateJoinTableResponse.status !== 201) {
2!
NEW
521
        throw new DatabaseError(`Could not create data for join table \
×
522
          ${formConfig.via.tableName}. ${updateJoinTableResponse?.error?.message}`, {
523
          errorCode: FormBackendErrorCode.JOIN_TABLE_DATA_CREATION_FAILED,
524
          detailedMessage: updateJoinTableResponse?.error?.message,
525
          tableName: updateJoinTableResponse?.error?.message
526
        });
527
      }
528
    }
529
  }
530

531
  async #removeFromJoinTable(itemId: string, joins: DataItem[], formConfig: FormConfigInternal | JoinTable) {
532
    this.#logger.debug(`Removing join table data for item ${itemId}`);
1✔
533

534
    // @ts-ignore
535
    const deleteFromJoinTableResponse = await this.#pgClient
1✔
536
      .schema(formConfig.via.schema || this.#pgClient.schemaName!)
2✔
537
      .from(formConfig.via.tableName)
538
      .delete()
539
      .eq(formConfig.on.self, itemId)
540
      .in(formConfig.on.other, joins.map(j => j[formConfig.dataSource.idColumn]));
1✔
541

542
    if (deleteFromJoinTableResponse.status !== 204) {
1!
NEW
543
      throw new DatabaseError(`Could not delete data for join table \
×
544
          ${formConfig.via.tableName}. ${deleteFromJoinTableResponse?.error?.message}`, {
545
        errorCode: FormBackendErrorCode.JOIN_TABLE_DATA_DELETION_FAILED,
546
        detailedMessage: deleteFromJoinTableResponse?.error?.message,
547
        tableName: deleteFromJoinTableResponse?.error?.message
548
      });
549
    }
550
  }
551

552
  #filterIncludedProperties(item: DataItem, formConfig: FormConfigInternal | JoinTable) {
553
    const includedProperties = formConfig.includedProperties;
3✔
554
    const filteredData = includedProperties.reduce((acc: Record<string, any>, prop: any) => {
3✔
555
      if (!(prop in item)) {
3!
556
        return { ...acc };
×
557
      }
558
      return {
3✔
559
        ...acc,
560
        [prop]: item[prop]
561
      };
562
    }, {});
563
    if (formConfig.relationship === Relationship.ONE_TO_MANY) {
3!
564
      const fkCol = formConfig.on.self;
×
565
      if (fkCol in item) {
×
566
        filteredData[fkCol] = item[fkCol];
×
567
      }
568
    }
569
    return filteredData;
3✔
570
  }
571

572
  #filterJoinTables(item: DataItem, formConfig: FormConfigInternal | JoinTable) {
573
    const joinTablesToExclude = [
3✔
574
      ...FormConfigProcessor.getPropsWithJoinTables(formConfig, Relationship.MANY_TO_MANY),
575
      ...FormConfigProcessor.getPropsWithJoinTables(formConfig, Relationship.ONE_TO_MANY)
576
    ];
577
    const dataClone = structuredClone(item);
3✔
578
    return joinTablesToExclude.reduce((acc, prop) => {
3✔
579
      if (prop in item) {
×
580
        const {
581
          // eslint-disable-next-line @typescript-eslint/naming-convention,@typescript-eslint/no-unused-vars
582
          [prop]: _,
583
          ...dataWithoutProp
584
        } = acc;
×
585
        return dataWithoutProp;
×
586
      }
587
      return {
×
588
        ...acc
589
      };
590
    }, dataClone);
591
  }
592

593
  #removeId(item: DataItem, formConfig: FormConfigInternal | JoinTable) {
594
    const idColumn = formConfig.dataSource.idColumn;
3✔
595
    if (!idColumn) {
3✔
596
      return item;
1✔
597
    }
598
    const {
599
      // eslint-disable-next-line @typescript-eslint/naming-convention
600
      [idColumn]: _,
601
      ...dataWithoutId
602
    } = item;
2✔
603
    return dataWithoutId;
2✔
604
  }
605

606
  #isGeometryFormat(type: string) {
607
    const isGeometryFormat = /^(\w+\.)?geometry($|\((POINT)(, ?\d+)?\)$)/.test(type);
3✔
608
    const isLocationFormat = type === 'location';
3✔
609
    const isGeometrySelectionFormat = type === 'geometrySelection';
3✔
610
    return isGeometryFormat || isLocationFormat || isGeometrySelectionFormat;
3✔
611
  }
612

613
  #replaceEmptyGeometries(item: DataItem, formConfig: FormConfigInternal | JoinTable) {
614
    const itemClone = structuredClone(item);
3✔
615

616
    Object.keys(formConfig.properties)
3✔
617
      .filter(prop => this.#isGeometryFormat(formConfig.properties[prop].format), this)
3✔
618
      .forEach(prop => {
619
        if (itemClone[prop] === '') {
×
620
          itemClone[prop] = null;
×
621
        }
622
      });
623

624
    return itemClone;
3✔
625
  }
626

627
  #sanitizeData(item: DataItem, formConfig: FormConfigInternal | JoinTable) {
628
    const filteredData = this.#filterIncludedProperties(item, formConfig);
3✔
629
    const sanitizedDateData = this.replaceEmptyDateFields(filteredData, formConfig.properties);
3✔
630
    const dataWithoutJoinTables = this.#filterJoinTables(sanitizedDateData, formConfig);
3✔
631
    const dataWithEmptyGeometries = this.#replaceEmptyGeometries(dataWithoutJoinTables, formConfig);
3✔
632
    const filesValid = this.#fileProcessor.validateFileFields(dataWithEmptyGeometries, formConfig);
3✔
633
    if (!filesValid) {
3!
NEW
634
      throw new GenericRequestError('Invalid file(s).', 400, {
×
635
        errorCode: FormBackendErrorCode.INVALID_FILE,
636
        detailedMessage: 'One or more files are invalid or missing.'
637
      });
638
    }
639
    // TODO check if we always want to remove the id or only if it is set to readonly
640
    return this.#removeId(dataWithEmptyGeometries, formConfig);
3✔
641
  }
642

643
  async #updateData(item: DataItem, itemId: string, formConfig: FormConfigInternal | JoinTable) {
644
    const tableName = formConfig.dataSource.tableName;
×
645
    const idColumn = formConfig.dataSource.idColumn;
×
646
    let isSuccess = false;
×
647
    if (!tableName || !idColumn) {
×
648
      return isSuccess;
×
649
    }
650

651
    // @ts-ignore
652
    const response = await this.#pgClient
×
653
      .schema(formConfig.dataSource.schema || this.#pgClient.schemaName!)
×
654
      .from(tableName)
655
      .update(item)
656
      .eq(idColumn, itemId);
657

658
    if (response.status !== 204) {
×
659
      this.#logger.error(`Could not update data for item ${itemId} of table ${tableName}. ${response?.error?.message}`);
×
660
      return isSuccess;
×
661
    }
662
    isSuccess = true;
×
663
    return isSuccess;
×
664
  }
665

666
  #createFormSelectStatement(formConfig: FormConfigInternal) {
667
    const idColumn = formConfig.dataSource.idColumn;
×
668
    const columns = structuredClone(formConfig.includedPropertiesTable)
×
669
      .filter((c: string) => {
670
        // filter out join table columns as they will be handled separately
671
        const columnConfig = formConfig.properties[c];
×
672
        const isJoinTable = columnConfig.resolveTable;
×
673
        return !isJoinTable;
×
674
      })
675
      .map((c: string) => {
676
        const prop = formConfig.properties[c];
×
677
        // create nested structure for lookup tables for allowing
678
        // ordering by lookup table display value.
679
        if (prop.resolveAsEnum && prop.resolveLookup) {
×
680
          const lookupTables = formConfig.dataSource.lookupTables;
×
681
          if (!lookupTables) {
×
NEW
682
            throw new GenericRequestError('Cannot create select statement. Missing lookupTables.', 500, {
×
683
              errorCode: FormBackendErrorCode.MISSING_LOOKUP_TABLES
684
            });
685
          }
686
          const refCol = lookupTables[c].includedProperties?.[0];
×
687
          if (!refCol) {
×
NEW
688
            throw new GenericRequestError(`Cannot create select statement. Missing includedProperties in
×
689
            lookupTable ${c}.`, 500, {
690
              errorCode: FormBackendErrorCode.MISSING_LOOKUP_COLUMNS
691
            });
692
          }
693
          return `${c}(${refCol},${prop.resolveToColumn})`;
×
694
        }
695
        return c;
×
696
      });
697

698
    if (idColumn && columns && !columns.includes(idColumn)) {
×
699
      columns.unshift(idColumn);
×
700
    }
701
    return columns.toString();
×
702
  }
703

704
  #createItemSelectStatement(formConfig: FormConfigInternal | JoinTable) {
705
    const idColumn = formConfig.dataSource.idColumn;
×
706
    const columns = formConfig.includedProperties
×
707
      .map((c: string) => {
708
        const columnConfig = formConfig.properties[c];
×
709
        const isJoinTable = !!columnConfig.resolveTable;
×
710
        if (isJoinTable) {
×
711
          const joinConfig = formConfig.dataSource.joinTables?.[c];
×
712
          if (!joinConfig) {
×
NEW
713
            throw new GenericRequestError(`No join table config found for property ${c}`, 500, {
×
714
              errorCode: FormBackendErrorCode.MISSING_LOOKUP_COLUMNS
715
            });
716
          }
717
          if (joinConfig.relationship === Relationship.MANY_TO_ONE) {
×
718
            return `${c}:${joinConfig.dataSource.tableName}(${this.#createItemSelectStatement(joinConfig)})`;
×
719
          }
720
          return `${c}(${this.#createItemSelectStatement(joinConfig)})`;
×
721
        }
722
        return c;
×
723
      });
724

725
    if (idColumn && columns && !columns.includes(idColumn)) {
×
726
      columns.unshift(idColumn);
×
727
    }
728
    return columns.toString();
×
729
  }
730

731
  #addFilterQuery(query: PostgrestFilterBuilder<any, any, any, any>, filter: FilterType) {
732
    const filterValue = filter?.filterValue?.trim();
×
733
    switch (filter?.filterOp) {
×
734
      case 'equals':
735
        query.eq(filter.filterKey, filterValue);
×
736
        break;
×
737
      case 'notEqual':
738
        query.neq(filter.filterKey, filterValue);
×
739
        break;
×
740
      case 'greaterThan':
741
        query.gt(filter.filterKey, filterValue);
×
742
        break;
×
743
      case 'lessThan':
744
        query.lt(filter.filterKey, filterValue);
×
745
        break;
×
746
      case 'like':
747
      case 'contains':
748
        query.ilike(filter.filterKey, `%${filterValue}%`);
×
749
        break;
×
750
      default:
751
        break;
×
752
    }
753
  }
754

755
  #addOrderQuery(query: PostgrestFilterBuilder<any, any, any, any>, formConfig: FormConfigInternal) {
756
    const orderBy = formConfig.dataSource.orderBy;
×
757
    const ascending = formConfig.dataSource.order === 'asc';
×
758
    const resolveAsEnum = formConfig.properties[orderBy].resolveAsEnum;
×
759
    const resolveLookup = formConfig.properties[orderBy].resolveLookup;
×
760

761
    let orderQuery;
762
    if (resolveAsEnum && resolveLookup) {
×
763
      orderQuery = `${orderBy}(${formConfig.properties[orderBy].resolveToColumn})`;
×
764
    } else {
765
      orderQuery = orderBy;
×
766
    }
767
    query.order(orderQuery, { ascending });
×
768
  }
769

770
  /**
771
   * Reconstruct table data from an orderable structure to a flat structure.
772
   * @param data The data to reconstruct.
773
   * @param formConfig The formConfig.
774
   * @returns The reconstructed table data.
775
   */
776
  #reconstructTableData (data: DataItem[], formConfig: FormConfigInternal) {
777
    const colsToReconstruct = formConfig.includedPropertiesTable
×
778
      .filter((c: string) => formConfig.properties[c].resolveAsEnum && formConfig.properties[c].resolveLookup);
×
779

780
    return data.map(item => {
×
781
      const itemClone = structuredClone(item);
×
782
      colsToReconstruct.forEach((c: string) => {
×
783
        const lookupTables = formConfig.dataSource.lookupTables;
×
784
        if (!lookupTables) {
×
NEW
785
          throw new GenericRequestError('Cannot reconstruct data. Missing lookupTables.', 500, {
×
786
            errorCode: FormBackendErrorCode.MISSING_LOOKUP_COLUMNS
787
          });
788
        }
789
        const refCol = lookupTables[c].includedProperties?.[0];
×
790
        if (!refCol) {
×
NEW
791
          throw new GenericRequestError(`Cannot reconstruct data. Missing includedProperties in
×
792
           lookupTable ${c}.`, 500, {
793
            errorCode: FormBackendErrorCode.MISSING_LOOKUP_COLUMNS
794
          });
795
        }
796
        itemClone[c] = itemClone[c]?.[refCol];
×
797
      });
798
      return itemClone;
×
799
    });
800
  }
801
}
802

803
export default DataProcessor;
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc