Skip to content

Commit 1e6e375

Browse files
committed
feat(server): real draft/publish separation and per-submission field snapshots
- Publish freezes a publishedData snapshot; Save draft no longer touches it - Public schema, page & submit serve/validate the published snapshot only (legacy published forms fall back to their current copy) - Submissions store a fields snapshot at submit time so the record stays accurate after the form's fields are added, removed or renamed
1 parent 1402096 commit 1e6e375

5 files changed

Lines changed: 70 additions & 34 deletions

File tree

admin/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ export interface FormSubmission {
8686
id: number;
8787
form: { id: number; title: string; slug: string };
8888
data: Record<string, any>;
89+
// schema snapshot captured at submit time (accurate even after the form changes)
90+
fields?: Array<Pick<FormField, 'name' | 'label' | 'type' | 'order' | 'options'>>;
8991
metadata: Record<string, any>;
9092
ipAddress?: string;
9193
userAgent?: string;

server/src/content-types/form-submission/schema.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
"inversedBy": "submissions"
2323
},
2424
"data": { "type": "json", "required": true },
25+
"fields": { "type": "json" },
2526
"metadata": { "type": "json", "default": {} },
2627
"ipAddress": { "type": "string" },
2728
"userAgent": { "type": "string" },

server/src/content-types/form/schema.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"description": { "type": "text" },
2121
"fields": { "type": "json", "required": true, "default": [] },
2222
"conditionalLogic": { "type": "json", "default": [] },
23+
"publishedData": { "type": "json" },
2324
"settings": {
2425
"type": "json",
2526
"default": {

server/src/services/form.ts

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ function generateSlug(title: string): string {
99
return `${base}-${Date.now()}`;
1010
}
1111

12+
// Frozen copy of the working form stored at publish time — what the public is served.
13+
function snapshotOf(data: any) {
14+
return {
15+
title: data.title,
16+
description: data.description ?? '',
17+
fields: data.fields ?? [],
18+
conditionalLogic: data.conditionalLogic ?? [],
19+
settings: data.settings ?? {},
20+
};
21+
}
22+
1223
export default ({ strapi }: { strapi: any }) => ({
1324
async find() {
1425
const forms = await strapi.db.query(FORM_UID).findMany({
@@ -40,11 +51,23 @@ export default ({ strapi }: { strapi: any }) => ({
4051

4152
async create(data: any) {
4253
const slug = generateSlug(data.title || 'form');
43-
return strapi.db.query(FORM_UID).create({ data: { ...data, slug } });
54+
const payload: any = { ...data, slug };
55+
// Publishing captures the working copy as the immutable live snapshot.
56+
if (data.publishedAt) payload.publishedData = snapshotOf(data);
57+
return strapi.db.query(FORM_UID).create({ data: payload });
4458
},
4559

4660
async update(id: number, data: any) {
47-
return strapi.db.query(FORM_UID).update({ where: { id }, data });
61+
const payload: any = { ...data };
62+
if (data.publishedAt) {
63+
// Publish: freeze the current working copy as the live version.
64+
payload.publishedData = snapshotOf(data);
65+
} else {
66+
// Save draft: never unpublish or overwrite what the public sees.
67+
delete payload.publishedAt;
68+
delete payload.publishedData;
69+
}
70+
return strapi.db.query(FORM_UID).update({ where: { id }, data: payload });
4871
},
4972

5073
async delete(id: number) {
@@ -55,7 +78,7 @@ export default ({ strapi }: { strapi: any }) => ({
5578
const original = await strapi.db.query(FORM_UID).findOne({ where: { id } });
5679
if (!original) return null;
5780

58-
const { id: _id, slug, createdAt, updatedAt, publishedAt, ...rest } = original;
81+
const { id: _id, slug, createdAt, updatedAt, publishedAt, publishedData, ...rest } = original;
5982
return strapi.db.query(FORM_UID).create({
6083
data: {
6184
...rest,
@@ -65,35 +88,31 @@ export default ({ strapi }: { strapi: any }) => ({
6588
});
6689
},
6790

91+
// Public views serve the published snapshot only — draft edits never leak.
6892
async getPublicSchemaById(id: number) {
6993
const form = await this.findOne(id);
70-
if (!form) return null;
71-
return {
72-
data: {
73-
id: form.id,
74-
title: form.title,
75-
slug: form.slug,
76-
description: form.description,
77-
fields: form.fields || [],
78-
settings: form.settings || {},
79-
},
80-
};
94+
return publicSchemaFrom(form);
8195
},
8296

8397
async getPublicSchema(slug: string) {
8498
const form = await this.findBySlug(slug);
85-
if (!form) return null;
86-
87-
return {
88-
data: {
89-
id: form.id,
90-
title: form.title,
91-
slug: form.slug,
92-
description: form.description,
93-
fields: form.fields || [],
94-
conditionalLogic: form.conditionalLogic || [],
95-
settings: form.settings || {},
96-
},
97-
};
99+
return publicSchemaFrom(form);
98100
},
99101
});
102+
103+
function publicSchemaFrom(form: any) {
104+
if (!form || !form.publishedAt) return null;
105+
// Legacy fallback: forms published before publishedData existed serve their current copy.
106+
const p = form.publishedData || snapshotOf(form);
107+
return {
108+
data: {
109+
id: form.id,
110+
slug: form.slug,
111+
title: p.title,
112+
description: p.description,
113+
fields: p.fields || [],
114+
conditionalLogic: p.conditionalLogic || [],
115+
settings: p.settings || {},
116+
},
117+
};
118+
}

server/src/services/submission.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,19 @@ export default ({ strapi }: { strapi: any }) => ({
1313
.service('form')
1414
.findBySlug(slug);
1515

16-
if (!form) {
16+
// Only a published form accepts submissions, and against its published
17+
// snapshot — not the working draft the admin may be editing.
18+
if (!form || !form.publishedAt) {
1719
const err: any = new Error('Form not found');
1820
err.name = 'NotFoundError';
1921
throw err;
2022
}
23+
// Legacy fallback: forms published before publishedData existed use their current copy.
24+
const pd = form.publishedData || { fields: form.fields, settings: form.settings };
25+
const live = { fields: pd.fields || [], settings: pd.settings || {} };
2126

2227
const validationService = strapi.plugin(PLUGIN_ID).service('validation');
23-
const result = validationService.validate(form.fields || [], body);
28+
const result = validationService.validate(live.fields, body);
2429

2530
if (!result.valid) {
2631
const err: any = new Error('Validation failed');
@@ -30,13 +35,13 @@ export default ({ strapi }: { strapi: any }) => ({
3035
}
3136

3237
// Honeypot check — bot filled the hidden trap field, pretend success and drop it
33-
if (form.settings?.enableHoneypot && body._fc_hp) {
34-
return { success: true, successMessage: form.settings?.successMessage };
38+
if (live.settings?.enableHoneypot && body._fc_hp) {
39+
return { success: true, successMessage: live.settings?.successMessage };
3540
}
3641

3742
// Rate limit per form + IP over the last hour
38-
if (form.settings?.enableRateLimit && meta.ip) {
39-
const max = Number(form.settings?.maxSubmissionsPerHour) || 60;
43+
if (live.settings?.enableRateLimit && meta.ip) {
44+
const max = Number(live.settings?.maxSubmissionsPerHour) || 60;
4045
const since = new Date(Date.now() - 60 * 60 * 1000);
4146
// ponytail: naive per-request count, fine at this scale; move to a store if throughput matters
4247
const recent = await strapi.db.query(SUBMISSION_UID).count({
@@ -52,11 +57,19 @@ export default ({ strapi }: { strapi: any }) => ({
5257
// strip honeypot before persisting so it never lands in stored data
5358
const { _fc_hp, ...clean } = body;
5459

60+
// Freeze the field schema used at submit time so the record stays accurate
61+
// even if the form's fields are later added, removed or renamed.
62+
const decorative = ['heading', 'paragraph', 'divider'];
63+
const fieldsSnapshot = (live.fields || [])
64+
.filter((f: any) => !decorative.includes(f.type))
65+
.map((f: any) => ({ name: f.name, label: f.label, type: f.type, order: f.order, options: f.options }));
66+
5567
try {
5668
await strapi.db.query(SUBMISSION_UID).create({
5769
data: {
5870
form: form.id,
5971
data: clean,
72+
fields: fieldsSnapshot,
6073
ipAddress: meta.ip,
6174
userAgent: meta.userAgent,
6275
status: 'new',
@@ -67,7 +80,7 @@ export default ({ strapi }: { strapi: any }) => ({
6780
throw createErr;
6881
}
6982

70-
return { success: true, successMessage: form.settings?.successMessage };
83+
return { success: true, successMessage: live.settings?.successMessage };
7184
},
7285

7386
async find(formId: number, query: any = {}) {

0 commit comments

Comments
 (0)