@actuate-media/cli 0.8.0 → 0.10.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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +30 -28
- package/CHANGELOG.md +18 -0
- package/README.md +30 -0
- package/dist/__tests__/form-seed.test.d.ts +2 -0
- package/dist/__tests__/form-seed.test.d.ts.map +1 -0
- package/dist/__tests__/form-seed.test.js +79 -0
- package/dist/__tests__/form-seed.test.js.map +1 -0
- package/dist/__tests__/seed.test.js +73 -0
- package/dist/__tests__/seed.test.js.map +1 -1
- package/dist/commands/seed.d.ts +30 -1
- package/dist/commands/seed.d.ts.map +1 -1
- package/dist/commands/seed.js +146 -21
- package/dist/commands/seed.js.map +1 -1
- package/dist/utils/form-seed.d.ts +15 -0
- package/dist/utils/form-seed.d.ts.map +1 -0
- package/dist/utils/form-seed.js +97 -0
- package/dist/utils/form-seed.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/form-seed.test.ts +91 -0
- package/src/__tests__/seed.test.ts +97 -0
- package/src/commands/seed.ts +167 -21
- package/src/utils/form-seed.ts +137 -0
|
@@ -130,6 +130,103 @@ describe('seed document creation', () => {
|
|
|
130
130
|
})
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
describe('seed document upsert', () => {
|
|
134
|
+
function makeDb(existing: { id: string; status: string; publishedAt: Date | null } | null) {
|
|
135
|
+
const tx = {
|
|
136
|
+
document: {
|
|
137
|
+
create: vi.fn().mockResolvedValue({ id: 'doc-new' }),
|
|
138
|
+
update: vi.fn().mockResolvedValue({ id: existing?.id ?? 'doc-new' }),
|
|
139
|
+
},
|
|
140
|
+
version: { create: vi.fn() },
|
|
141
|
+
}
|
|
142
|
+
const db = {
|
|
143
|
+
document: { findFirst: vi.fn().mockResolvedValue(existing) },
|
|
144
|
+
$transaction: vi.fn(async (fn: any) => fn(tx)),
|
|
145
|
+
}
|
|
146
|
+
return { db, tx }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const doc = {
|
|
150
|
+
collection: 'pages',
|
|
151
|
+
status: 'PUBLISHED',
|
|
152
|
+
data: { title: 'Home v2', slug: 'home' },
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
it('updates an existing document matched by collection + slug', async () => {
|
|
156
|
+
const publishedAt = new Date('2026-01-01T00:00:00Z')
|
|
157
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'PUBLISHED', publishedAt })
|
|
158
|
+
|
|
159
|
+
const result = await createSeedDocument(db, 'admin-1', doc, { upsert: true })
|
|
160
|
+
|
|
161
|
+
expect(result).toBe('updated')
|
|
162
|
+
expect(db.document.findFirst).toHaveBeenCalledWith({
|
|
163
|
+
where: { collection: 'pages', slug: 'home', deletedAt: null },
|
|
164
|
+
select: { id: true, status: true, publishedAt: true },
|
|
165
|
+
})
|
|
166
|
+
expect(tx.document.create).not.toHaveBeenCalled()
|
|
167
|
+
expect(tx.document.update).toHaveBeenCalledWith({
|
|
168
|
+
where: { id: 'doc-1' },
|
|
169
|
+
data: expect.objectContaining({
|
|
170
|
+
title: 'Home v2',
|
|
171
|
+
status: 'PUBLISHED',
|
|
172
|
+
// Re-publishing an already-published doc keeps the original timestamp.
|
|
173
|
+
publishedAt,
|
|
174
|
+
updatedById: 'admin-1',
|
|
175
|
+
}),
|
|
176
|
+
})
|
|
177
|
+
expect(tx.version.create).toHaveBeenCalledWith({
|
|
178
|
+
data: expect.objectContaining({ documentId: 'doc-1', changeType: 'UPDATE' }),
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('stamps publishedAt on the transition into PUBLISHED', async () => {
|
|
183
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
|
|
184
|
+
|
|
185
|
+
await createSeedDocument(db, 'admin-1', doc, { upsert: true })
|
|
186
|
+
|
|
187
|
+
expect(tx.document.update).toHaveBeenCalledWith(
|
|
188
|
+
expect.objectContaining({
|
|
189
|
+
data: expect.objectContaining({ publishedAt: expect.any(Date) }),
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('creates when no document matches', async () => {
|
|
195
|
+
const { db, tx } = makeDb(null)
|
|
196
|
+
|
|
197
|
+
const result = await createSeedDocument(db, 'admin-1', doc, { upsert: true })
|
|
198
|
+
|
|
199
|
+
expect(result).toBe('created')
|
|
200
|
+
expect(tx.document.create).toHaveBeenCalled()
|
|
201
|
+
expect(tx.document.update).not.toHaveBeenCalled()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('always creates slugless documents (no upsert key)', async () => {
|
|
205
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
|
|
206
|
+
|
|
207
|
+
const result = await createSeedDocument(
|
|
208
|
+
db,
|
|
209
|
+
'admin-1',
|
|
210
|
+
{ collection: 'pages', status: 'DRAFT', data: { title: 'No slug' } },
|
|
211
|
+
{ upsert: true },
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
expect(result).toBe('created')
|
|
215
|
+
expect(db.document.findFirst).not.toHaveBeenCalled()
|
|
216
|
+
expect(tx.document.create).toHaveBeenCalled()
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('does not look up existing documents without the upsert flag', async () => {
|
|
220
|
+
const { db, tx } = makeDb({ id: 'doc-1', status: 'DRAFT', publishedAt: null })
|
|
221
|
+
|
|
222
|
+
const result = await createSeedDocument(db, 'admin-1', doc)
|
|
223
|
+
|
|
224
|
+
expect(result).toBe('created')
|
|
225
|
+
expect(db.document.findFirst).not.toHaveBeenCalled()
|
|
226
|
+
expect(tx.document.create).toHaveBeenCalled()
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
133
230
|
describe('demo navigations', () => {
|
|
134
231
|
// The scaffold site layout fetches menus by the `main` and `footer` slugs, so
|
|
135
232
|
// the demo seed must provide exactly those — guard against slug drift.
|
package/src/commands/seed.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { pathToFileURL } from 'node:url'
|
|
|
7
7
|
import ora from 'ora'
|
|
8
8
|
import { logger } from '../utils/logger.js'
|
|
9
9
|
import { connectProjectDatabase } from '../utils/database.js'
|
|
10
|
+
import { validateFormSeeds } from '../utils/form-seed.js'
|
|
10
11
|
|
|
11
12
|
async function confirm(question: string): Promise<boolean> {
|
|
12
13
|
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
|
@@ -119,37 +120,101 @@ const DEMO_POSTS = [
|
|
|
119
120
|
},
|
|
120
121
|
]
|
|
121
122
|
|
|
122
|
-
|
|
123
|
+
// Canonical form seed shape: the form definition lives in document `data`
|
|
124
|
+
// with its own lifecycle `status: 'active'` (gates the public endpoints) and
|
|
125
|
+
// `fields` keyed by { id, key, label, type, required, sortOrder }. See
|
|
126
|
+
// `validateFormSeeds` — these are validated by the same rules as user seeds.
|
|
127
|
+
export const DEMO_FORMS = [
|
|
123
128
|
{
|
|
129
|
+
name: 'Contact Form',
|
|
124
130
|
title: 'Contact Form',
|
|
125
131
|
slug: 'contact-form',
|
|
132
|
+
status: 'active',
|
|
126
133
|
fields: [
|
|
127
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
134
|
+
{
|
|
135
|
+
id: 'contact-name',
|
|
136
|
+
key: 'name',
|
|
137
|
+
label: 'Name',
|
|
138
|
+
type: 'text',
|
|
139
|
+
required: true,
|
|
140
|
+
sortOrder: 0,
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: 'contact-email',
|
|
144
|
+
key: 'email',
|
|
145
|
+
label: 'Email',
|
|
146
|
+
type: 'email',
|
|
147
|
+
required: true,
|
|
148
|
+
sortOrder: 1,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
id: 'contact-message',
|
|
152
|
+
key: 'message',
|
|
153
|
+
label: 'Message',
|
|
154
|
+
type: 'textarea',
|
|
155
|
+
required: true,
|
|
156
|
+
sortOrder: 2,
|
|
157
|
+
},
|
|
130
158
|
],
|
|
131
|
-
submitLabel: 'Send Message',
|
|
132
159
|
successMessage: "Thanks for reaching out! We'll get back to you soon.",
|
|
133
160
|
},
|
|
134
161
|
{
|
|
162
|
+
name: 'Newsletter Signup',
|
|
135
163
|
title: 'Newsletter Signup',
|
|
136
164
|
slug: 'newsletter',
|
|
165
|
+
status: 'active',
|
|
137
166
|
fields: [
|
|
138
|
-
{
|
|
139
|
-
|
|
167
|
+
{
|
|
168
|
+
id: 'newsletter-email',
|
|
169
|
+
key: 'email',
|
|
170
|
+
label: 'Email',
|
|
171
|
+
type: 'email',
|
|
172
|
+
required: true,
|
|
173
|
+
sortOrder: 0,
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
id: 'newsletter-first-name',
|
|
177
|
+
key: 'firstName',
|
|
178
|
+
label: 'First name',
|
|
179
|
+
type: 'text',
|
|
180
|
+
required: false,
|
|
181
|
+
sortOrder: 1,
|
|
182
|
+
},
|
|
140
183
|
],
|
|
141
|
-
submitLabel: 'Subscribe',
|
|
142
184
|
successMessage: "You're subscribed! Check your inbox for confirmation.",
|
|
143
185
|
},
|
|
144
186
|
{
|
|
187
|
+
name: 'Feedback Form',
|
|
145
188
|
title: 'Feedback Form',
|
|
146
189
|
slug: 'feedback',
|
|
190
|
+
status: 'active',
|
|
147
191
|
fields: [
|
|
148
|
-
{
|
|
149
|
-
|
|
150
|
-
|
|
192
|
+
{
|
|
193
|
+
id: 'feedback-name',
|
|
194
|
+
key: 'name',
|
|
195
|
+
label: 'Name',
|
|
196
|
+
type: 'text',
|
|
197
|
+
required: false,
|
|
198
|
+
sortOrder: 0,
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'feedback-rating',
|
|
202
|
+
key: 'rating',
|
|
203
|
+
label: 'Rating',
|
|
204
|
+
type: 'select',
|
|
205
|
+
required: true,
|
|
206
|
+
sortOrder: 1,
|
|
207
|
+
options: ['1', '2', '3', '4', '5'].map((v) => ({ label: v, value: v })),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: 'feedback-comments',
|
|
211
|
+
key: 'comments',
|
|
212
|
+
label: 'Comments',
|
|
213
|
+
type: 'textarea',
|
|
214
|
+
required: false,
|
|
215
|
+
sortOrder: 2,
|
|
216
|
+
},
|
|
151
217
|
],
|
|
152
|
-
submitLabel: 'Submit Feedback',
|
|
153
218
|
successMessage: 'Thank you for your feedback!',
|
|
154
219
|
},
|
|
155
220
|
]
|
|
@@ -188,6 +253,7 @@ interface SeedOptions {
|
|
|
188
253
|
demo?: boolean
|
|
189
254
|
file?: string
|
|
190
255
|
reset?: boolean
|
|
256
|
+
upsert?: boolean
|
|
191
257
|
}
|
|
192
258
|
|
|
193
259
|
export interface NormalizedSeedDocument {
|
|
@@ -324,7 +390,7 @@ async function runSeed(options: SeedOptions): Promise<void> {
|
|
|
324
390
|
}
|
|
325
391
|
|
|
326
392
|
if (file) {
|
|
327
|
-
await seedFromFile(db, file)
|
|
393
|
+
await seedFromFile(db, file, { upsert: options.upsert })
|
|
328
394
|
}
|
|
329
395
|
} catch (err) {
|
|
330
396
|
const message = err instanceof Error ? err.message : String(err)
|
|
@@ -380,19 +446,63 @@ export async function createSeedDocument(
|
|
|
380
446
|
db: any,
|
|
381
447
|
userId: string,
|
|
382
448
|
doc: NormalizedSeedDocument,
|
|
383
|
-
|
|
449
|
+
options: { upsert?: boolean } = {},
|
|
450
|
+
): Promise<'created' | 'updated'> {
|
|
384
451
|
const { extractPlainText, hashContent, sanitizeHtml } = await import('@actuate-media/cms-core')
|
|
385
452
|
const data = sanitizeSeedData(doc.data, sanitizeHtml) as Record<string, unknown>
|
|
386
453
|
const serialized = JSON.stringify(data)
|
|
387
454
|
const plainText = extractPlainText(serialized)
|
|
388
455
|
const contentHash = await hashContent(serialized)
|
|
456
|
+
const slug = typeof data.slug === 'string' ? data.slug : null
|
|
457
|
+
const title = typeof data.title === 'string' ? data.title : null
|
|
458
|
+
|
|
459
|
+
// Upsert key: collection + slug. Slugless documents can't be re-identified,
|
|
460
|
+
// so they always insert (same as a plain seed run).
|
|
461
|
+
const existing =
|
|
462
|
+
options.upsert && slug
|
|
463
|
+
? await db.document.findFirst({
|
|
464
|
+
where: { collection: doc.collection, slug, deletedAt: null },
|
|
465
|
+
select: { id: true, status: true, publishedAt: true },
|
|
466
|
+
})
|
|
467
|
+
: null
|
|
468
|
+
|
|
469
|
+
if (existing) {
|
|
470
|
+
await db.$transaction(async (tx: any) => {
|
|
471
|
+
await tx.document.update({
|
|
472
|
+
where: { id: existing.id },
|
|
473
|
+
data: {
|
|
474
|
+
title,
|
|
475
|
+
data,
|
|
476
|
+
status: doc.status,
|
|
477
|
+
// Stamp publishedAt only on the transition into PUBLISHED.
|
|
478
|
+
publishedAt:
|
|
479
|
+
doc.status === 'PUBLISHED'
|
|
480
|
+
? (existing.publishedAt ?? new Date())
|
|
481
|
+
: existing.publishedAt,
|
|
482
|
+
updatedById: userId,
|
|
483
|
+
plainText,
|
|
484
|
+
contentHash,
|
|
485
|
+
},
|
|
486
|
+
})
|
|
487
|
+
|
|
488
|
+
await tx.version.create({
|
|
489
|
+
data: {
|
|
490
|
+
documentId: existing.id,
|
|
491
|
+
data,
|
|
492
|
+
changedById: userId,
|
|
493
|
+
changeType: 'UPDATE',
|
|
494
|
+
},
|
|
495
|
+
})
|
|
496
|
+
})
|
|
497
|
+
return 'updated'
|
|
498
|
+
}
|
|
389
499
|
|
|
390
500
|
await db.$transaction(async (tx: any) => {
|
|
391
501
|
const created = await tx.document.create({
|
|
392
502
|
data: {
|
|
393
503
|
collection: doc.collection,
|
|
394
|
-
title
|
|
395
|
-
slug
|
|
504
|
+
title,
|
|
505
|
+
slug,
|
|
396
506
|
data,
|
|
397
507
|
status: doc.status,
|
|
398
508
|
publishedAt: doc.status === 'PUBLISHED' ? new Date() : null,
|
|
@@ -412,6 +522,7 @@ export async function createSeedDocument(
|
|
|
412
522
|
},
|
|
413
523
|
})
|
|
414
524
|
})
|
|
525
|
+
return 'created'
|
|
415
526
|
}
|
|
416
527
|
|
|
417
528
|
async function seedDemoData(db: any): Promise<void> {
|
|
@@ -486,7 +597,11 @@ async function seedDemoData(db: any): Promise<void> {
|
|
|
486
597
|
logger.info(` Users: ${usersCreated} (+ existing admin)`)
|
|
487
598
|
}
|
|
488
599
|
|
|
489
|
-
async function seedFromFile(
|
|
600
|
+
async function seedFromFile(
|
|
601
|
+
db: any,
|
|
602
|
+
filePath: string,
|
|
603
|
+
options: { upsert?: boolean } = {},
|
|
604
|
+
): Promise<void> {
|
|
490
605
|
if (!existsSync(filePath)) {
|
|
491
606
|
logger.error(`File not found: ${filePath}`)
|
|
492
607
|
process.exit(1)
|
|
@@ -511,13 +626,32 @@ async function seedFromFile(db: any, filePath: string): Promise<void> {
|
|
|
511
626
|
const userId = adminUser.id
|
|
512
627
|
|
|
513
628
|
const normalized = normalizeSeedPayload(seedData)
|
|
629
|
+
|
|
630
|
+
// Forms have a stricter shape than generic documents (data.status gates the
|
|
631
|
+
// public endpoints; fields need key/label/type). Fail loudly BEFORE writing
|
|
632
|
+
// anything — a malformed form seed used to insert fine and then 404 on
|
|
633
|
+
// /api/cms/public/forms/:slug with no hint why.
|
|
634
|
+
const formErrors = validateFormSeeds(normalized.documents)
|
|
635
|
+
if (formErrors.length > 0) {
|
|
636
|
+
spinner.fail('Form seed validation failed — nothing was written:')
|
|
637
|
+
for (const error of formErrors) {
|
|
638
|
+
logger.error(` ${error}`)
|
|
639
|
+
}
|
|
640
|
+
logger.info(
|
|
641
|
+
' Expected shape: { "forms": [{ "status": "PUBLISHED", "data": { "name", "slug", "status": "active", "fields": [{ "key", "label", "type", … }] } }] }',
|
|
642
|
+
)
|
|
643
|
+
process.exit(1)
|
|
644
|
+
}
|
|
645
|
+
|
|
514
646
|
const { updateGlobal } = await import('@actuate-media/cms-core')
|
|
515
647
|
const ctx = { userId, role: 'ADMIN', db }
|
|
516
648
|
|
|
517
|
-
let
|
|
649
|
+
let createdCount = 0
|
|
650
|
+
let updatedCount = 0
|
|
518
651
|
for (const doc of normalized.documents) {
|
|
519
|
-
await createSeedDocument(db, userId, doc)
|
|
520
|
-
|
|
652
|
+
const result = await createSeedDocument(db, userId, doc, options)
|
|
653
|
+
if (result === 'updated') updatedCount++
|
|
654
|
+
else createdCount++
|
|
521
655
|
}
|
|
522
656
|
|
|
523
657
|
let globalCount = 0
|
|
@@ -526,7 +660,11 @@ async function seedFromFile(db: any, filePath: string): Promise<void> {
|
|
|
526
660
|
globalCount++
|
|
527
661
|
}
|
|
528
662
|
|
|
529
|
-
spinner.succeed(
|
|
663
|
+
spinner.succeed(
|
|
664
|
+
options.upsert
|
|
665
|
+
? `Seeded from ${filePath}: ${createdCount} created, ${updatedCount} updated, ${globalCount} globals.`
|
|
666
|
+
: `Seeded ${createdCount} documents and ${globalCount} globals from ${filePath}.`,
|
|
667
|
+
)
|
|
530
668
|
}
|
|
531
669
|
|
|
532
670
|
export function registerSeedCommand(program: Command): void {
|
|
@@ -536,6 +674,10 @@ export function registerSeedCommand(program: Command): void {
|
|
|
536
674
|
.option('--demo', 'Seed demo content (pages, posts, forms, users)')
|
|
537
675
|
.option('--file <path>', 'Seed from a JSON, JavaScript, or TypeScript file')
|
|
538
676
|
.option('--reset', 'Clear existing data before seeding')
|
|
677
|
+
.option(
|
|
678
|
+
'--upsert',
|
|
679
|
+
'Update existing documents matched by collection + slug instead of inserting duplicates',
|
|
680
|
+
)
|
|
539
681
|
.action(runSeed)
|
|
540
682
|
|
|
541
683
|
program
|
|
@@ -543,5 +685,9 @@ export function registerSeedCommand(program: Command): void {
|
|
|
543
685
|
.description('Populate the database from actuate.seed.json or a custom seed file')
|
|
544
686
|
.option('--file <path>', 'Seed from a JSON, JavaScript, or TypeScript file')
|
|
545
687
|
.option('--reset', 'Clear existing data before seeding')
|
|
688
|
+
.option(
|
|
689
|
+
'--upsert',
|
|
690
|
+
'Update existing documents matched by collection + slug instead of inserting duplicates',
|
|
691
|
+
)
|
|
546
692
|
.action(runSeed)
|
|
547
693
|
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation for `forms`-collection seed entries.
|
|
3
|
+
*
|
|
4
|
+
* The form definition lives in `document.data` and has its own lifecycle
|
|
5
|
+
* `data.status` (`active` | `draft` | `archived`) that gates the public
|
|
6
|
+
* endpoints — a separate axis from the *document envelope* status
|
|
7
|
+
* (`DRAFT` | `PUBLISHED`). Seeding a form with the wrong shape used to fail
|
|
8
|
+
* silently: the document row was created, but `/api/cms/public/forms/:slug`
|
|
9
|
+
* 404'd (not `active`) or rendered nothing (fields missing `key`/`label`).
|
|
10
|
+
* This validator turns those mistakes into actionable errors at seed time.
|
|
11
|
+
*
|
|
12
|
+
* Type-only imports keep cms-core a runtime peer dependency (the CLI loads it
|
|
13
|
+
* lazily); the literal lists below are typed against the cms-core contracts so
|
|
14
|
+
* a typo here fails the build.
|
|
15
|
+
*/
|
|
16
|
+
import type { FormFieldType, FormStatus } from '@actuate-media/cms-core'
|
|
17
|
+
|
|
18
|
+
const VALID_FIELD_TYPES: readonly FormFieldType[] = [
|
|
19
|
+
'text',
|
|
20
|
+
'email',
|
|
21
|
+
'phone',
|
|
22
|
+
'textarea',
|
|
23
|
+
'select',
|
|
24
|
+
'multiselect',
|
|
25
|
+
'radio',
|
|
26
|
+
'checkbox',
|
|
27
|
+
'date',
|
|
28
|
+
'number',
|
|
29
|
+
'url',
|
|
30
|
+
'hidden',
|
|
31
|
+
'file',
|
|
32
|
+
'consent',
|
|
33
|
+
'richtext',
|
|
34
|
+
'divider',
|
|
35
|
+
'honeypot',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const VALID_FORM_STATUSES: readonly FormStatus[] = ['active', 'draft', 'archived']
|
|
39
|
+
|
|
40
|
+
/** Document-envelope statuses people mistakenly put in `data.status`. */
|
|
41
|
+
const ENVELOPE_STATUSES = new Set(['DRAFT', 'PUBLISHED', 'SCHEDULED', 'ARCHIVED'])
|
|
42
|
+
|
|
43
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate one form's `data` payload. Returns human-readable errors —
|
|
49
|
+
* empty array means the form will work with the public endpoints
|
|
50
|
+
* (`GET /api/cms/public/forms/:slug` + `/submit`) and `<ActuateForm>`.
|
|
51
|
+
*/
|
|
52
|
+
export function validateFormSeedData(data: unknown, label = 'form'): string[] {
|
|
53
|
+
const errors: string[] = []
|
|
54
|
+
if (!isRecord(data)) {
|
|
55
|
+
return [`${label}: form data must be an object.`]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (typeof data.slug !== 'string' || data.slug.trim() === '') {
|
|
59
|
+
errors.push(
|
|
60
|
+
`${label}: missing "slug" (string). The public endpoints look forms up by slug — e.g. /api/cms/public/forms/contact.`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const name = data.name ?? data.title
|
|
65
|
+
if (typeof name !== 'string' || name.trim() === '') {
|
|
66
|
+
errors.push(`${label}: missing "name" (string) — shown in the admin Forms list and inbox.`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const status = data.status
|
|
70
|
+
if (status === undefined || status === null) {
|
|
71
|
+
errors.push(
|
|
72
|
+
`${label}: missing "status". Set data.status to "active" to enable the public form endpoints. ` +
|
|
73
|
+
`Note: this is the FORM's lifecycle status inside data — separate from the document envelope status ` +
|
|
74
|
+
`(DRAFT/PUBLISHED) set next to data.`,
|
|
75
|
+
)
|
|
76
|
+
} else if (typeof status === 'string' && ENVELOPE_STATUSES.has(status)) {
|
|
77
|
+
errors.push(
|
|
78
|
+
`${label}: data.status "${status}" looks like a document envelope status. The form definition's ` +
|
|
79
|
+
`data.status must be one of ${VALID_FORM_STATUSES.map((s) => `"${s}"`).join(' | ')} — use "active" ` +
|
|
80
|
+
`to enable the public endpoints. Put DRAFT/PUBLISHED on the document's top-level "status" key instead.`,
|
|
81
|
+
)
|
|
82
|
+
} else if (!VALID_FORM_STATUSES.includes(status as FormStatus)) {
|
|
83
|
+
errors.push(
|
|
84
|
+
`${label}: invalid data.status ${JSON.stringify(status)} — must be one of ` +
|
|
85
|
+
`${VALID_FORM_STATUSES.map((s) => `"${s}"`).join(' | ')}.`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const fields = data.fields
|
|
90
|
+
if (!Array.isArray(fields) || fields.length === 0) {
|
|
91
|
+
errors.push(
|
|
92
|
+
`${label}: missing "fields" (non-empty array). Each field needs at least { key, label, type }.`,
|
|
93
|
+
)
|
|
94
|
+
return errors
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
fields.forEach((field, i) => {
|
|
98
|
+
const fieldLabel = `${label}: fields[${i}]`
|
|
99
|
+
if (!isRecord(field)) {
|
|
100
|
+
errors.push(`${fieldLabel}: must be an object with at least { key, label, type }.`)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (typeof field.key !== 'string' || field.key.trim() === '') {
|
|
104
|
+
errors.push(
|
|
105
|
+
typeof field.name === 'string'
|
|
106
|
+
? `${fieldLabel}: uses "name" — the form schema calls this "key". Rename name → key (and add a human-readable "label").`
|
|
107
|
+
: `${fieldLabel}: missing "key" (string) — the submission payload is keyed by it.`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
if (typeof field.label !== 'string' || field.label.trim() === '') {
|
|
111
|
+
errors.push(`${fieldLabel}: missing "label" (string) — shown to visitors and in the inbox.`)
|
|
112
|
+
}
|
|
113
|
+
if (!VALID_FIELD_TYPES.includes(field.type as FormFieldType)) {
|
|
114
|
+
errors.push(
|
|
115
|
+
`${fieldLabel}: invalid type ${JSON.stringify(field.type)} — must be one of ${VALID_FIELD_TYPES.join(', ')}.`,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
return errors
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Validate every `forms`-collection document in a normalized seed payload.
|
|
125
|
+
* Returns all errors across all forms (so one run surfaces everything).
|
|
126
|
+
*/
|
|
127
|
+
export function validateFormSeeds(
|
|
128
|
+
documents: ReadonlyArray<{ collection: string; data: Record<string, unknown> }>,
|
|
129
|
+
): string[] {
|
|
130
|
+
const errors: string[] = []
|
|
131
|
+
documents.forEach((doc, i) => {
|
|
132
|
+
if (doc.collection !== 'forms') return
|
|
133
|
+
const slug = typeof doc.data.slug === 'string' ? doc.data.slug : `#${i + 1}`
|
|
134
|
+
errors.push(...validateFormSeedData(doc.data, `forms "${slug}"`))
|
|
135
|
+
})
|
|
136
|
+
return errors
|
|
137
|
+
}
|