@reverso/api 0.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.
Files changed (74) hide show
  1. package/README.md +47 -0
  2. package/dist/index.d.ts +41 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +56 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/plugins/auth.d.ts +72 -0
  7. package/dist/plugins/auth.d.ts.map +1 -0
  8. package/dist/plugins/auth.js +173 -0
  9. package/dist/plugins/auth.js.map +1 -0
  10. package/dist/plugins/database.d.ts +19 -0
  11. package/dist/plugins/database.d.ts.map +1 -0
  12. package/dist/plugins/database.js +23 -0
  13. package/dist/plugins/database.js.map +1 -0
  14. package/dist/plugins/index.d.ts +5 -0
  15. package/dist/plugins/index.d.ts.map +1 -0
  16. package/dist/plugins/index.js +5 -0
  17. package/dist/plugins/index.js.map +1 -0
  18. package/dist/routes/auth.d.ts +6 -0
  19. package/dist/routes/auth.d.ts.map +1 -0
  20. package/dist/routes/auth.js +258 -0
  21. package/dist/routes/auth.js.map +1 -0
  22. package/dist/routes/content.d.ts +8 -0
  23. package/dist/routes/content.d.ts.map +1 -0
  24. package/dist/routes/content.js +339 -0
  25. package/dist/routes/content.js.map +1 -0
  26. package/dist/routes/forms.d.ts +8 -0
  27. package/dist/routes/forms.d.ts.map +1 -0
  28. package/dist/routes/forms.js +953 -0
  29. package/dist/routes/forms.js.map +1 -0
  30. package/dist/routes/index.d.ts +19 -0
  31. package/dist/routes/index.d.ts.map +1 -0
  32. package/dist/routes/index.js +31 -0
  33. package/dist/routes/index.js.map +1 -0
  34. package/dist/routes/media.d.ts +8 -0
  35. package/dist/routes/media.d.ts.map +1 -0
  36. package/dist/routes/media.js +400 -0
  37. package/dist/routes/media.js.map +1 -0
  38. package/dist/routes/pages.d.ts +8 -0
  39. package/dist/routes/pages.d.ts.map +1 -0
  40. package/dist/routes/pages.js +220 -0
  41. package/dist/routes/pages.js.map +1 -0
  42. package/dist/routes/redirects.d.ts +8 -0
  43. package/dist/routes/redirects.d.ts.map +1 -0
  44. package/dist/routes/redirects.js +462 -0
  45. package/dist/routes/redirects.js.map +1 -0
  46. package/dist/routes/schema.d.ts +8 -0
  47. package/dist/routes/schema.d.ts.map +1 -0
  48. package/dist/routes/schema.js +151 -0
  49. package/dist/routes/schema.js.map +1 -0
  50. package/dist/routes/sitemap.d.ts +8 -0
  51. package/dist/routes/sitemap.d.ts.map +1 -0
  52. package/dist/routes/sitemap.js +144 -0
  53. package/dist/routes/sitemap.js.map +1 -0
  54. package/dist/server.d.ts +47 -0
  55. package/dist/server.d.ts.map +1 -0
  56. package/dist/server.js +218 -0
  57. package/dist/server.js.map +1 -0
  58. package/dist/types.d.ts +91 -0
  59. package/dist/types.d.ts.map +1 -0
  60. package/dist/types.js +5 -0
  61. package/dist/types.js.map +1 -0
  62. package/dist/utils/index.d.ts +5 -0
  63. package/dist/utils/index.d.ts.map +1 -0
  64. package/dist/utils/index.js +5 -0
  65. package/dist/utils/index.js.map +1 -0
  66. package/dist/utils/security.d.ts +32 -0
  67. package/dist/utils/security.d.ts.map +1 -0
  68. package/dist/utils/security.js +154 -0
  69. package/dist/utils/security.js.map +1 -0
  70. package/dist/validation.d.ts +402 -0
  71. package/dist/validation.d.ts.map +1 -0
  72. package/dist/validation.js +308 -0
  73. package/dist/validation.js.map +1 -0
  74. package/package.json +76 -0
@@ -0,0 +1,953 @@
1
+ /**
2
+ * Forms routes.
3
+ * Handles forms, form fields, and form submissions CRUD operations.
4
+ */
5
+ import { createForm, createFormField, createFormSubmission, deleteForm, deleteFormField, deleteFormSubmission, duplicateForm, duplicateFormFields, getFormById, getFormBySlug, getFormFields, getFormSubmissionById, getFormSubmissionStats, getFormSubmissions, getFormSubmissionsByFormId, getForms, parseFormFieldConfig, parseFormSettings, parseFormSteps, parseNotifyEmails, parseSubmissionAttachments, parseSubmissionData, publishForm, recordWebhookSent, reorderFormFields, unpublishForm, updateForm, updateFormField, updateFormSubmissionStatus, } from '@reverso/db';
6
+ import { formCreateSchema, formFieldCreateSchema, formFieldUpdateSchema, formSubmissionCreateSchema, formUpdateSchema, idParamSchema, paginationSchema, slugParamSchema, } from '../validation.js';
7
+ import { isUrlSafeForSSRF, isRedirectUrlSafe, generateWebhookSignature, } from '../utils/security.js';
8
+ const formsRoutes = async (fastify) => {
9
+ // ============================================
10
+ // FORM CRUD
11
+ // ============================================
12
+ /**
13
+ * GET /forms
14
+ * List all forms.
15
+ */
16
+ fastify.get('/forms', async (request, reply) => {
17
+ try {
18
+ const db = request.db;
19
+ const forms = await getForms(db);
20
+ return {
21
+ success: true,
22
+ data: forms.map((form) => ({
23
+ id: form.id,
24
+ name: form.name,
25
+ slug: form.slug,
26
+ description: form.description,
27
+ status: form.status,
28
+ isMultiStep: form.isMultiStep,
29
+ createdAt: form.createdAt,
30
+ updatedAt: form.updatedAt,
31
+ })),
32
+ };
33
+ }
34
+ catch (error) {
35
+ fastify.log.error(error, 'Failed to list forms');
36
+ return reply.status(500).send({
37
+ success: false,
38
+ error: 'Internal error',
39
+ message: 'Failed to list forms',
40
+ });
41
+ }
42
+ });
43
+ /**
44
+ * POST /forms
45
+ * Create a new form.
46
+ */
47
+ fastify.post('/forms', async (request, reply) => {
48
+ try {
49
+ const bodyResult = formCreateSchema.safeParse(request.body);
50
+ if (!bodyResult.success) {
51
+ return reply.status(400).send({
52
+ success: false,
53
+ error: 'Validation error',
54
+ message: bodyResult.error.issues[0]?.message ?? 'Invalid request body',
55
+ });
56
+ }
57
+ const db = request.db;
58
+ const input = bodyResult.data;
59
+ // Check if slug already exists
60
+ const existing = await getFormBySlug(db, input.slug);
61
+ if (existing) {
62
+ return reply.status(400).send({
63
+ success: false,
64
+ error: 'Validation error',
65
+ message: `Form with slug "${input.slug}" already exists`,
66
+ });
67
+ }
68
+ const form = await createForm(db, input);
69
+ return {
70
+ success: true,
71
+ data: {
72
+ id: form.id,
73
+ name: form.name,
74
+ slug: form.slug,
75
+ description: form.description,
76
+ status: form.status,
77
+ createdAt: form.createdAt,
78
+ },
79
+ };
80
+ }
81
+ catch (error) {
82
+ fastify.log.error(error, 'Failed to create form');
83
+ return reply.status(500).send({
84
+ success: false,
85
+ error: 'Internal error',
86
+ message: 'Failed to create form',
87
+ });
88
+ }
89
+ });
90
+ /**
91
+ * GET /forms/:id
92
+ * Get form by ID with fields.
93
+ */
94
+ fastify.get('/forms/:id', async (request, reply) => {
95
+ try {
96
+ const paramResult = idParamSchema.safeParse(request.params);
97
+ if (!paramResult.success) {
98
+ return reply.status(400).send({
99
+ success: false,
100
+ error: 'Validation error',
101
+ message: 'Invalid ID format',
102
+ });
103
+ }
104
+ const db = request.db;
105
+ const { id } = paramResult.data;
106
+ const form = await getFormById(db, id);
107
+ if (!form) {
108
+ return reply.status(404).send({
109
+ success: false,
110
+ error: 'Not found',
111
+ message: `Form with ID "${id}" not found`,
112
+ });
113
+ }
114
+ const fields = await getFormFields(db, id);
115
+ const stats = await getFormSubmissionStats(db, id);
116
+ return {
117
+ success: true,
118
+ data: {
119
+ id: form.id,
120
+ name: form.name,
121
+ slug: form.slug,
122
+ description: form.description,
123
+ status: form.status,
124
+ isMultiStep: form.isMultiStep,
125
+ steps: parseFormSteps(form),
126
+ settings: parseFormSettings(form),
127
+ notifyEmails: parseNotifyEmails(form),
128
+ notifyOnSubmission: form.notifyOnSubmission,
129
+ webhookUrl: form.webhookUrl,
130
+ webhookEnabled: form.webhookEnabled,
131
+ honeypotEnabled: form.honeypotEnabled,
132
+ rateLimitPerMinute: form.rateLimitPerMinute,
133
+ createdAt: form.createdAt,
134
+ updatedAt: form.updatedAt,
135
+ fields: fields.map((field) => ({
136
+ id: field.id,
137
+ name: field.name,
138
+ type: field.type,
139
+ label: field.label,
140
+ placeholder: field.placeholder,
141
+ help: field.help,
142
+ required: field.required,
143
+ validation: field.validation,
144
+ config: parseFormFieldConfig(field),
145
+ width: field.width,
146
+ step: field.step,
147
+ sortOrder: field.sortOrder,
148
+ })),
149
+ submissionStats: stats,
150
+ },
151
+ };
152
+ }
153
+ catch (error) {
154
+ fastify.log.error(error, 'Failed to get form');
155
+ return reply.status(500).send({
156
+ success: false,
157
+ error: 'Internal error',
158
+ message: 'Failed to get form',
159
+ });
160
+ }
161
+ });
162
+ /**
163
+ * PUT /forms/:id
164
+ * Update a form.
165
+ */
166
+ fastify.put('/forms/:id', async (request, reply) => {
167
+ try {
168
+ const paramResult = idParamSchema.safeParse(request.params);
169
+ if (!paramResult.success) {
170
+ return reply.status(400).send({
171
+ success: false,
172
+ error: 'Validation error',
173
+ message: 'Invalid ID format',
174
+ });
175
+ }
176
+ const bodyResult = formUpdateSchema.safeParse(request.body);
177
+ if (!bodyResult.success) {
178
+ return reply.status(400).send({
179
+ success: false,
180
+ error: 'Validation error',
181
+ message: bodyResult.error.issues[0]?.message ?? 'Invalid request body',
182
+ });
183
+ }
184
+ const db = request.db;
185
+ const { id } = paramResult.data;
186
+ const form = await updateForm(db, id, bodyResult.data);
187
+ if (!form) {
188
+ return reply.status(404).send({
189
+ success: false,
190
+ error: 'Not found',
191
+ message: `Form with ID "${id}" not found`,
192
+ });
193
+ }
194
+ return {
195
+ success: true,
196
+ data: {
197
+ id: form.id,
198
+ name: form.name,
199
+ slug: form.slug,
200
+ description: form.description,
201
+ status: form.status,
202
+ updatedAt: form.updatedAt,
203
+ },
204
+ };
205
+ }
206
+ catch (error) {
207
+ fastify.log.error(error, 'Failed to update form');
208
+ return reply.status(500).send({
209
+ success: false,
210
+ error: 'Internal error',
211
+ message: 'Failed to update form',
212
+ });
213
+ }
214
+ });
215
+ /**
216
+ * DELETE /forms/:id
217
+ * Delete a form.
218
+ */
219
+ fastify.delete('/forms/:id', async (request, reply) => {
220
+ try {
221
+ const paramResult = idParamSchema.safeParse(request.params);
222
+ if (!paramResult.success) {
223
+ return reply.status(400).send({
224
+ success: false,
225
+ error: 'Validation error',
226
+ message: 'Invalid ID format',
227
+ });
228
+ }
229
+ const db = request.db;
230
+ const { id } = paramResult.data;
231
+ const form = await deleteForm(db, id);
232
+ if (!form) {
233
+ return reply.status(404).send({
234
+ success: false,
235
+ error: 'Not found',
236
+ message: `Form with ID "${id}" not found`,
237
+ });
238
+ }
239
+ return {
240
+ success: true,
241
+ data: { id: form.id, deleted: true },
242
+ };
243
+ }
244
+ catch (error) {
245
+ fastify.log.error(error, 'Failed to delete form');
246
+ return reply.status(500).send({
247
+ success: false,
248
+ error: 'Internal error',
249
+ message: 'Failed to delete form',
250
+ });
251
+ }
252
+ });
253
+ /**
254
+ * POST /forms/:id/duplicate
255
+ * Duplicate a form.
256
+ */
257
+ fastify.post('/forms/:id/duplicate', async (request, reply) => {
258
+ try {
259
+ const paramResult = idParamSchema.safeParse(request.params);
260
+ if (!paramResult.success) {
261
+ return reply.status(400).send({
262
+ success: false,
263
+ error: 'Validation error',
264
+ message: 'Invalid ID format',
265
+ });
266
+ }
267
+ const bodyResult = slugParamSchema.safeParse(request.body);
268
+ if (!bodyResult.success) {
269
+ return reply.status(400).send({
270
+ success: false,
271
+ error: 'Validation error',
272
+ message: 'Invalid slug format',
273
+ });
274
+ }
275
+ const db = request.db;
276
+ const { id } = paramResult.data;
277
+ const { slug } = bodyResult.data;
278
+ // Check if new slug already exists
279
+ const existingSlug = await getFormBySlug(db, slug);
280
+ if (existingSlug) {
281
+ return reply.status(400).send({
282
+ success: false,
283
+ error: 'Validation error',
284
+ message: `Form with slug "${slug}" already exists`,
285
+ });
286
+ }
287
+ const newForm = await duplicateForm(db, id, slug);
288
+ if (!newForm) {
289
+ return reply.status(404).send({
290
+ success: false,
291
+ error: 'Not found',
292
+ message: `Form with ID "${id}" not found`,
293
+ });
294
+ }
295
+ // Duplicate fields
296
+ await duplicateFormFields(db, id, newForm.id);
297
+ return {
298
+ success: true,
299
+ data: {
300
+ id: newForm.id,
301
+ name: newForm.name,
302
+ slug: newForm.slug,
303
+ status: newForm.status,
304
+ createdAt: newForm.createdAt,
305
+ },
306
+ };
307
+ }
308
+ catch (error) {
309
+ fastify.log.error(error, 'Failed to duplicate form');
310
+ return reply.status(500).send({
311
+ success: false,
312
+ error: 'Internal error',
313
+ message: 'Failed to duplicate form',
314
+ });
315
+ }
316
+ });
317
+ /**
318
+ * PUT /forms/:id/publish
319
+ * Publish a form.
320
+ */
321
+ fastify.put('/forms/:id/publish', async (request, reply) => {
322
+ try {
323
+ const paramResult = idParamSchema.safeParse(request.params);
324
+ if (!paramResult.success) {
325
+ return reply.status(400).send({
326
+ success: false,
327
+ error: 'Validation error',
328
+ message: 'Invalid ID format',
329
+ });
330
+ }
331
+ const db = request.db;
332
+ const { id } = paramResult.data;
333
+ const form = await publishForm(db, id);
334
+ if (!form) {
335
+ return reply.status(404).send({
336
+ success: false,
337
+ error: 'Not found',
338
+ message: `Form with ID "${id}" not found`,
339
+ });
340
+ }
341
+ return {
342
+ success: true,
343
+ data: {
344
+ id: form.id,
345
+ status: form.status,
346
+ updatedAt: form.updatedAt,
347
+ },
348
+ };
349
+ }
350
+ catch (error) {
351
+ fastify.log.error(error, 'Failed to publish form');
352
+ return reply.status(500).send({
353
+ success: false,
354
+ error: 'Internal error',
355
+ message: 'Failed to publish form',
356
+ });
357
+ }
358
+ });
359
+ /**
360
+ * PUT /forms/:id/unpublish
361
+ * Unpublish a form.
362
+ */
363
+ fastify.put('/forms/:id/unpublish', async (request, reply) => {
364
+ try {
365
+ const paramResult = idParamSchema.safeParse(request.params);
366
+ if (!paramResult.success) {
367
+ return reply.status(400).send({
368
+ success: false,
369
+ error: 'Validation error',
370
+ message: 'Invalid ID format',
371
+ });
372
+ }
373
+ const db = request.db;
374
+ const { id } = paramResult.data;
375
+ const form = await unpublishForm(db, id);
376
+ if (!form) {
377
+ return reply.status(404).send({
378
+ success: false,
379
+ error: 'Not found',
380
+ message: `Form with ID "${id}" not found`,
381
+ });
382
+ }
383
+ return {
384
+ success: true,
385
+ data: {
386
+ id: form.id,
387
+ status: form.status,
388
+ updatedAt: form.updatedAt,
389
+ },
390
+ };
391
+ }
392
+ catch (error) {
393
+ fastify.log.error(error, 'Failed to unpublish form');
394
+ return reply.status(500).send({
395
+ success: false,
396
+ error: 'Internal error',
397
+ message: 'Failed to unpublish form',
398
+ });
399
+ }
400
+ });
401
+ // ============================================
402
+ // FORM FIELDS
403
+ // ============================================
404
+ /**
405
+ * POST /forms/:id/fields
406
+ * Add a field to a form.
407
+ */
408
+ fastify.post('/forms/:id/fields', async (request, reply) => {
409
+ try {
410
+ const paramResult = idParamSchema.safeParse(request.params);
411
+ if (!paramResult.success) {
412
+ return reply.status(400).send({
413
+ success: false,
414
+ error: 'Validation error',
415
+ message: 'Invalid ID format',
416
+ });
417
+ }
418
+ const bodyResult = formFieldCreateSchema.safeParse(request.body);
419
+ if (!bodyResult.success) {
420
+ return reply.status(400).send({
421
+ success: false,
422
+ error: 'Validation error',
423
+ message: bodyResult.error.issues[0]?.message ?? 'Invalid request body',
424
+ });
425
+ }
426
+ const db = request.db;
427
+ const { id } = paramResult.data;
428
+ // Check form exists
429
+ const form = await getFormById(db, id);
430
+ if (!form) {
431
+ return reply.status(404).send({
432
+ success: false,
433
+ error: 'Not found',
434
+ message: `Form with ID "${id}" not found`,
435
+ });
436
+ }
437
+ const field = await createFormField(db, {
438
+ formId: id,
439
+ ...bodyResult.data,
440
+ });
441
+ return {
442
+ success: true,
443
+ data: {
444
+ id: field.id,
445
+ name: field.name,
446
+ type: field.type,
447
+ label: field.label,
448
+ sortOrder: field.sortOrder,
449
+ createdAt: field.createdAt,
450
+ },
451
+ };
452
+ }
453
+ catch (error) {
454
+ fastify.log.error(error, 'Failed to add form field');
455
+ return reply.status(500).send({
456
+ success: false,
457
+ error: 'Internal error',
458
+ message: 'Failed to add form field',
459
+ });
460
+ }
461
+ });
462
+ /**
463
+ * PUT /forms/:id/fields/:fieldId
464
+ * Update a form field.
465
+ */
466
+ fastify.put('/forms/:id/fields/:fieldId', async (request, reply) => {
467
+ try {
468
+ const paramResult = idParamSchema.safeParse({ id: request.params.fieldId });
469
+ if (!paramResult.success) {
470
+ return reply.status(400).send({
471
+ success: false,
472
+ error: 'Validation error',
473
+ message: 'Invalid field ID format',
474
+ });
475
+ }
476
+ const bodyResult = formFieldUpdateSchema.safeParse(request.body);
477
+ if (!bodyResult.success) {
478
+ return reply.status(400).send({
479
+ success: false,
480
+ error: 'Validation error',
481
+ message: bodyResult.error.issues[0]?.message ?? 'Invalid request body',
482
+ });
483
+ }
484
+ const db = request.db;
485
+ const { id: fieldId } = paramResult.data;
486
+ const field = await updateFormField(db, fieldId, bodyResult.data);
487
+ if (!field) {
488
+ return reply.status(404).send({
489
+ success: false,
490
+ error: 'Not found',
491
+ message: `Field with ID "${fieldId}" not found`,
492
+ });
493
+ }
494
+ return {
495
+ success: true,
496
+ data: {
497
+ id: field.id,
498
+ name: field.name,
499
+ type: field.type,
500
+ updatedAt: field.updatedAt,
501
+ },
502
+ };
503
+ }
504
+ catch (error) {
505
+ fastify.log.error(error, 'Failed to update form field');
506
+ return reply.status(500).send({
507
+ success: false,
508
+ error: 'Internal error',
509
+ message: 'Failed to update form field',
510
+ });
511
+ }
512
+ });
513
+ /**
514
+ * DELETE /forms/:id/fields/:fieldId
515
+ * Delete a form field.
516
+ */
517
+ fastify.delete('/forms/:id/fields/:fieldId', async (request, reply) => {
518
+ try {
519
+ const db = request.db;
520
+ const { fieldId } = request.params;
521
+ const field = await deleteFormField(db, fieldId);
522
+ if (!field) {
523
+ return reply.status(404).send({
524
+ success: false,
525
+ error: 'Not found',
526
+ message: `Field with ID "${fieldId}" not found`,
527
+ });
528
+ }
529
+ return {
530
+ success: true,
531
+ data: { id: field.id, deleted: true },
532
+ };
533
+ }
534
+ catch (error) {
535
+ fastify.log.error(error, 'Failed to delete form field');
536
+ return reply.status(500).send({
537
+ success: false,
538
+ error: 'Internal error',
539
+ message: 'Failed to delete form field',
540
+ });
541
+ }
542
+ });
543
+ /**
544
+ * PUT /forms/:id/fields/reorder
545
+ * Reorder form fields.
546
+ */
547
+ fastify.put('/forms/:id/fields/reorder', async (request, reply) => {
548
+ try {
549
+ const paramResult = idParamSchema.safeParse(request.params);
550
+ if (!paramResult.success) {
551
+ return reply.status(400).send({
552
+ success: false,
553
+ error: 'Validation error',
554
+ message: 'Invalid ID format',
555
+ });
556
+ }
557
+ const { fieldIds } = request.body;
558
+ if (!Array.isArray(fieldIds)) {
559
+ return reply.status(400).send({
560
+ success: false,
561
+ error: 'Validation error',
562
+ message: 'fieldIds must be an array',
563
+ });
564
+ }
565
+ const db = request.db;
566
+ const { id } = paramResult.data;
567
+ await reorderFormFields(db, id, fieldIds);
568
+ return {
569
+ success: true,
570
+ data: { reordered: true },
571
+ };
572
+ }
573
+ catch (error) {
574
+ fastify.log.error(error, 'Failed to reorder form fields');
575
+ return reply.status(500).send({
576
+ success: false,
577
+ error: 'Internal error',
578
+ message: 'Failed to reorder form fields',
579
+ });
580
+ }
581
+ });
582
+ // ============================================
583
+ // FORM SUBMISSIONS
584
+ // ============================================
585
+ /**
586
+ * GET /forms/:id/submissions
587
+ * List submissions for a form.
588
+ */
589
+ fastify.get('/forms/:id/submissions', async (request, reply) => {
590
+ try {
591
+ const paramResult = idParamSchema.safeParse(request.params);
592
+ if (!paramResult.success) {
593
+ return reply.status(400).send({
594
+ success: false,
595
+ error: 'Validation error',
596
+ message: 'Invalid ID format',
597
+ });
598
+ }
599
+ const queryResult = paginationSchema.safeParse(request.query);
600
+ const { limit, offset } = queryResult.success ? queryResult.data : {};
601
+ const db = request.db;
602
+ const { id } = paramResult.data;
603
+ const submissions = await getFormSubmissionsByFormId(db, id, { limit, offset });
604
+ const stats = await getFormSubmissionStats(db, id);
605
+ return {
606
+ success: true,
607
+ data: submissions.map((sub) => ({
608
+ id: sub.id,
609
+ data: parseSubmissionData(sub),
610
+ status: sub.status,
611
+ ipAddress: sub.ipAddress,
612
+ referrer: sub.referrer,
613
+ createdAt: sub.createdAt,
614
+ })),
615
+ meta: {
616
+ total: stats.total,
617
+ new: stats.new,
618
+ read: stats.read,
619
+ spam: stats.spam,
620
+ },
621
+ };
622
+ }
623
+ catch (error) {
624
+ fastify.log.error(error, 'Failed to list submissions');
625
+ return reply.status(500).send({
626
+ success: false,
627
+ error: 'Internal error',
628
+ message: 'Failed to list submissions',
629
+ });
630
+ }
631
+ });
632
+ /**
633
+ * GET /forms/:id/submissions/:subId
634
+ * Get a specific submission.
635
+ */
636
+ fastify.get('/forms/:id/submissions/:subId', async (request, reply) => {
637
+ try {
638
+ const db = request.db;
639
+ const { subId } = request.params;
640
+ const submission = await getFormSubmissionById(db, subId);
641
+ if (!submission) {
642
+ return reply.status(404).send({
643
+ success: false,
644
+ error: 'Not found',
645
+ message: `Submission with ID "${subId}" not found`,
646
+ });
647
+ }
648
+ return {
649
+ success: true,
650
+ data: {
651
+ id: submission.id,
652
+ formId: submission.formId,
653
+ data: parseSubmissionData(submission),
654
+ status: submission.status,
655
+ ipAddress: submission.ipAddress,
656
+ userAgent: submission.userAgent,
657
+ referrer: submission.referrer,
658
+ attachments: parseSubmissionAttachments(submission),
659
+ webhookSentAt: submission.webhookSentAt,
660
+ createdAt: submission.createdAt,
661
+ },
662
+ };
663
+ }
664
+ catch (error) {
665
+ fastify.log.error(error, 'Failed to get submission');
666
+ return reply.status(500).send({
667
+ success: false,
668
+ error: 'Internal error',
669
+ message: 'Failed to get submission',
670
+ });
671
+ }
672
+ });
673
+ /**
674
+ * PUT /forms/:id/submissions/:subId/status
675
+ * Update submission status.
676
+ */
677
+ fastify.put('/forms/:id/submissions/:subId/status', async (request, reply) => {
678
+ try {
679
+ const db = request.db;
680
+ const { subId } = request.params;
681
+ const { status } = request.body;
682
+ if (!['new', 'read', 'spam', 'archived'].includes(status)) {
683
+ return reply.status(400).send({
684
+ success: false,
685
+ error: 'Validation error',
686
+ message: 'Invalid status. Must be one of: new, read, spam, archived',
687
+ });
688
+ }
689
+ const submission = await updateFormSubmissionStatus(db, subId, status);
690
+ if (!submission) {
691
+ return reply.status(404).send({
692
+ success: false,
693
+ error: 'Not found',
694
+ message: `Submission with ID "${subId}" not found`,
695
+ });
696
+ }
697
+ return {
698
+ success: true,
699
+ data: {
700
+ id: submission.id,
701
+ status: submission.status,
702
+ },
703
+ };
704
+ }
705
+ catch (error) {
706
+ fastify.log.error(error, 'Failed to update submission status');
707
+ return reply.status(500).send({
708
+ success: false,
709
+ error: 'Internal error',
710
+ message: 'Failed to update submission status',
711
+ });
712
+ }
713
+ });
714
+ /**
715
+ * DELETE /forms/:id/submissions/:subId
716
+ * Delete a submission.
717
+ */
718
+ fastify.delete('/forms/:id/submissions/:subId', async (request, reply) => {
719
+ try {
720
+ const db = request.db;
721
+ const { subId } = request.params;
722
+ const submission = await deleteFormSubmission(db, subId);
723
+ if (!submission) {
724
+ return reply.status(404).send({
725
+ success: false,
726
+ error: 'Not found',
727
+ message: `Submission with ID "${subId}" not found`,
728
+ });
729
+ }
730
+ return {
731
+ success: true,
732
+ data: { id: submission.id, deleted: true },
733
+ };
734
+ }
735
+ catch (error) {
736
+ fastify.log.error(error, 'Failed to delete submission');
737
+ return reply.status(500).send({
738
+ success: false,
739
+ error: 'Internal error',
740
+ message: 'Failed to delete submission',
741
+ });
742
+ }
743
+ });
744
+ /**
745
+ * POST /forms/:id/submissions/export
746
+ * Export submissions as CSV.
747
+ */
748
+ fastify.post('/forms/:id/submissions/export', async (request, reply) => {
749
+ try {
750
+ const paramResult = idParamSchema.safeParse(request.params);
751
+ if (!paramResult.success) {
752
+ return reply.status(400).send({
753
+ success: false,
754
+ error: 'Validation error',
755
+ message: 'Invalid ID format',
756
+ });
757
+ }
758
+ const db = request.db;
759
+ const { id } = paramResult.data;
760
+ const form = await getFormById(db, id);
761
+ if (!form) {
762
+ return reply.status(404).send({
763
+ success: false,
764
+ error: 'Not found',
765
+ message: `Form with ID "${id}" not found`,
766
+ });
767
+ }
768
+ const fields = await getFormFields(db, id);
769
+ const submissions = await getFormSubmissionsByFormId(db, id, { limit: 10000 });
770
+ // Build CSV
771
+ const headers = ['ID', 'Status', 'IP', 'Created At', ...fields.map((f) => f.label || f.name)];
772
+ const rows = submissions.map((sub) => {
773
+ const data = parseSubmissionData(sub);
774
+ return [
775
+ sub.id,
776
+ sub.status,
777
+ sub.ipAddress ?? '',
778
+ sub.createdAt?.toISOString() ?? '',
779
+ ...fields.map((f) => String(data[f.name] ?? '')),
780
+ ];
781
+ });
782
+ const csv = [headers, ...rows].map((row) => row.map(escapeCSV).join(',')).join('\n');
783
+ reply.header('Content-Type', 'text/csv');
784
+ reply.header('Content-Disposition', `attachment; filename="${form.slug}-submissions.csv"`);
785
+ return csv;
786
+ }
787
+ catch (error) {
788
+ fastify.log.error(error, 'Failed to export submissions');
789
+ return reply.status(500).send({
790
+ success: false,
791
+ error: 'Internal error',
792
+ message: 'Failed to export submissions',
793
+ });
794
+ }
795
+ });
796
+ // ============================================
797
+ // PUBLIC FORM SUBMISSION
798
+ // ============================================
799
+ /**
800
+ * POST /public/forms/:slug/submit
801
+ * Public form submission endpoint (no auth required).
802
+ * Rate limited: 10 submissions per minute per IP.
803
+ */
804
+ fastify.post('/public/forms/:slug/submit', {
805
+ config: {
806
+ rateLimit: {
807
+ max: 10,
808
+ timeWindow: '1 minute',
809
+ keyGenerator: (request) => request.ip,
810
+ },
811
+ },
812
+ }, async (request, reply) => {
813
+ try {
814
+ const paramResult = slugParamSchema.safeParse(request.params);
815
+ if (!paramResult.success) {
816
+ return reply.status(400).send({
817
+ success: false,
818
+ error: 'Validation error',
819
+ message: 'Invalid slug format',
820
+ });
821
+ }
822
+ const bodyResult = formSubmissionCreateSchema.safeParse(request.body);
823
+ if (!bodyResult.success) {
824
+ return reply.status(400).send({
825
+ success: false,
826
+ error: 'Validation error',
827
+ message: bodyResult.error.issues[0]?.message ?? 'Invalid request body',
828
+ });
829
+ }
830
+ const db = request.db;
831
+ const { slug } = paramResult.data;
832
+ const { data, honeypot } = bodyResult.data;
833
+ const form = await getFormBySlug(db, slug);
834
+ if (!form || form.status !== 'published') {
835
+ return reply.status(404).send({
836
+ success: false,
837
+ error: 'Not found',
838
+ message: 'Form not found or not published',
839
+ });
840
+ }
841
+ // Check honeypot
842
+ if (form.honeypotEnabled && honeypot) {
843
+ // Silently accept but mark as spam
844
+ const submission = await createFormSubmission(db, {
845
+ formId: form.id,
846
+ data,
847
+ ipAddress: request.ip,
848
+ userAgent: request.headers['user-agent'],
849
+ referrer: request.headers.referer,
850
+ });
851
+ await updateFormSubmissionStatus(db, submission.id, 'spam');
852
+ return {
853
+ success: true,
854
+ data: { id: submission.id },
855
+ };
856
+ }
857
+ // Create submission
858
+ const submission = await createFormSubmission(db, {
859
+ formId: form.id,
860
+ data,
861
+ ipAddress: request.ip,
862
+ userAgent: request.headers['user-agent'],
863
+ referrer: request.headers.referer,
864
+ });
865
+ // Send webhook if enabled
866
+ if (form.webhookEnabled && form.webhookUrl) {
867
+ // SSRF Protection: Validate webhook URL
868
+ const urlCheck = isUrlSafeForSSRF(form.webhookUrl);
869
+ if (!urlCheck.safe) {
870
+ fastify.log.warn({ url: form.webhookUrl, reason: urlCheck.reason }, 'Blocked unsafe webhook URL');
871
+ }
872
+ else {
873
+ try {
874
+ const timestamp = Date.now();
875
+ const webhookPayload = JSON.stringify({
876
+ formId: form.id,
877
+ formSlug: form.slug,
878
+ submissionId: submission.id,
879
+ data,
880
+ submittedAt: submission.createdAt,
881
+ timestamp,
882
+ });
883
+ // Build headers with optional HMAC signature
884
+ const headers = {
885
+ 'Content-Type': 'application/json',
886
+ 'X-Webhook-Timestamp': String(timestamp),
887
+ };
888
+ if (form.webhookSecret) {
889
+ // Use HMAC-SHA256 signature instead of plain secret
890
+ headers['X-Webhook-Signature'] = generateWebhookSignature(webhookPayload, form.webhookSecret);
891
+ }
892
+ const controller = new AbortController();
893
+ const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
894
+ const webhookResponse = await fetch(form.webhookUrl, {
895
+ method: 'POST',
896
+ headers,
897
+ body: webhookPayload,
898
+ signal: controller.signal,
899
+ redirect: 'error', // Don't follow redirects
900
+ });
901
+ clearTimeout(timeout);
902
+ await recordWebhookSent(db, submission.id, {
903
+ status: webhookResponse.status,
904
+ ok: webhookResponse.ok,
905
+ });
906
+ }
907
+ catch (webhookError) {
908
+ fastify.log.error(webhookError, 'Failed to send webhook');
909
+ }
910
+ }
911
+ }
912
+ const settings = parseFormSettings(form);
913
+ // Open Redirect Protection: Validate redirect URL
914
+ let safeRedirectUrl;
915
+ if (settings.redirectUrl) {
916
+ const redirectCheck = isRedirectUrlSafe(settings.redirectUrl);
917
+ if (redirectCheck.safe) {
918
+ safeRedirectUrl = settings.redirectUrl;
919
+ }
920
+ else {
921
+ fastify.log.warn({ url: settings.redirectUrl, reason: redirectCheck.reason }, 'Blocked unsafe redirect URL');
922
+ }
923
+ }
924
+ return {
925
+ success: true,
926
+ data: {
927
+ id: submission.id,
928
+ message: settings.successMessage ?? 'Form submitted successfully',
929
+ redirectUrl: safeRedirectUrl,
930
+ },
931
+ };
932
+ }
933
+ catch (error) {
934
+ fastify.log.error(error, 'Failed to submit form');
935
+ return reply.status(500).send({
936
+ success: false,
937
+ error: 'Internal error',
938
+ message: 'Failed to submit form',
939
+ });
940
+ }
941
+ });
942
+ };
943
+ /**
944
+ * Escape a value for CSV.
945
+ */
946
+ function escapeCSV(value) {
947
+ if (value.includes(',') || value.includes('"') || value.includes('\n')) {
948
+ return `"${value.replace(/"/g, '""')}"`;
949
+ }
950
+ return value;
951
+ }
952
+ export default formsRoutes;
953
+ //# sourceMappingURL=forms.js.map