create-prenta 1.3.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 (49) hide show
  1. package/CHANGELOG.md +964 -0
  2. package/LICENSE +21 -0
  3. package/assets/prisma/migrations/0001_init/migration.sql +941 -0
  4. package/assets/prisma/migrations/0002_media_usage_field_path/migration.sql +11 -0
  5. package/assets/prisma/migrations/0003_ai_crawler_hits/migration.sql +21 -0
  6. package/assets/prisma/migrations/0004_drop_search_vector/migration.sql +4 -0
  7. package/assets/prisma/migrations/0005_script_tag_delivery_controls/migration.sql +8 -0
  8. package/assets/prisma/migrations/0006_totp_last_step/migration.sql +4 -0
  9. package/assets/prisma/migrations/0007_workflow_fk_drop_dead_enums/migration.sql +27 -0
  10. package/assets/prisma/migrations/0008_form_delivery_attempts/migration.sql +29 -0
  11. package/assets/prisma/migrations/0009_webhook_secret_rotation/migration.sql +6 -0
  12. package/assets/prisma/migrations/0010_media_is_decorative/migration.sql +3 -0
  13. package/assets/prisma/migrations/0011_media_content_hash/migration.sql +4 -0
  14. package/assets/prisma/migrations/0012_rename_to_prenta/migration.sql +89 -0
  15. package/assets/prisma/migrations/migration_lock.toml +3 -0
  16. package/assets/prisma/schema.prisma +989 -0
  17. package/dist/index.d.ts +3 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +182 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/options.d.ts +160 -0
  22. package/dist/options.d.ts.map +1 -0
  23. package/dist/options.js +206 -0
  24. package/dist/options.js.map +1 -0
  25. package/dist/scaffold.d.ts +45 -0
  26. package/dist/scaffold.d.ts.map +1 -0
  27. package/dist/scaffold.js +2163 -0
  28. package/dist/scaffold.js.map +1 -0
  29. package/dist/templates/admin-page.ts.template.d.ts +24 -0
  30. package/dist/templates/admin-page.ts.template.d.ts.map +1 -0
  31. package/dist/templates/admin-page.ts.template.js +145 -0
  32. package/dist/templates/admin-page.ts.template.js.map +1 -0
  33. package/dist/templates/astro-site.d.ts +36 -0
  34. package/dist/templates/astro-site.d.ts.map +1 -0
  35. package/dist/templates/astro-site.js +918 -0
  36. package/dist/templates/astro-site.js.map +1 -0
  37. package/dist/templates/login-page.ts.template.d.ts +14 -0
  38. package/dist/templates/login-page.ts.template.d.ts.map +1 -0
  39. package/dist/templates/login-page.ts.template.js +143 -0
  40. package/dist/templates/login-page.ts.template.js.map +1 -0
  41. package/dist/templates/marketing-custom.d.ts +56 -0
  42. package/dist/templates/marketing-custom.d.ts.map +1 -0
  43. package/dist/templates/marketing-custom.js +814 -0
  44. package/dist/templates/marketing-custom.js.map +1 -0
  45. package/dist/templates/prenta.config.ts.template.d.ts +3 -0
  46. package/dist/templates/prenta.config.ts.template.d.ts.map +1 -0
  47. package/dist/templates/prenta.config.ts.template.js +253 -0
  48. package/dist/templates/prenta.config.ts.template.js.map +1 -0
  49. package/package.json +58 -0
@@ -0,0 +1,989 @@
1
+ // ─────────────────────────────────────────────────────────────────────────────
2
+ // Prenta CMS — Prisma schema
3
+ //
4
+ // AUTO-SYNCED from @prenta/core. Do NOT edit the model definitions
5
+ // by hand — they must match the bundled migrations and cms-core's API layer.
6
+ // (Generated by create-prenta/scripts/sync-prisma-assets.ts)
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+
9
+ generator client {
10
+ provider = "prisma-client"
11
+ output = "../generated/prisma"
12
+ previewFeatures = ["fullTextSearchPostgres", "relationJoins"]
13
+ }
14
+
15
+ datasource db {
16
+ provider = "postgresql"
17
+ }
18
+
19
+ // ─── Enums ──────────────────────────────────────────────────────────
20
+
21
+ enum DocumentStatus {
22
+ DRAFT
23
+ PUBLISHED
24
+ ARCHIVED
25
+ SCHEDULED
26
+ SCHEDULED_UNPUBLISH
27
+ }
28
+
29
+ enum UserRole {
30
+ ADMIN
31
+ EDITOR
32
+ AUTHOR
33
+ CLIENT
34
+ OWNER
35
+ VIEWER
36
+ }
37
+
38
+ enum InviteStatus {
39
+ PENDING
40
+ ACCEPTED
41
+ EXPIRED
42
+ CANCELLED
43
+ }
44
+
45
+ enum ChangeType {
46
+ CREATE
47
+ UPDATE
48
+ PUBLISH
49
+ RESTORE
50
+ DELETE
51
+ }
52
+
53
+ enum ContentIssueType {
54
+ // Document is published but missing a `metaDescription` /
55
+ // `seoDescription` field — the dashboard surfaces these so editors
56
+ // can fix the SERP snippet before a competitor outranks them.
57
+ MISSING_META_DESCRIPTION
58
+ // Document HTML contains <img> tags without alt attributes. Hurts
59
+ // both a11y and SEO. Counted per *document* not per image — so a
60
+ // page with 5 alt-less images is 1 row, not 5, with `details.count`
61
+ // capturing the per-document count.
62
+ MISSING_ALT_TEXT
63
+ // Internal link (same-origin URL or relative path) returned 404 /
64
+ // 410 / DNS-failure when the link checker probed it. `details.url`
65
+ // holds the offending URL.
66
+ BROKEN_INTERNAL_LINK
67
+ // Published document not touched in >90 days. Encourages refresh
68
+ // of stale content — particularly important for "best of YEAR"
69
+ // posts that decay rapidly.
70
+ OUTDATED_CONTENT
71
+ }
72
+
73
+ enum WorkflowStage {
74
+ DRAFT
75
+ IN_REVIEW
76
+ APPROVED
77
+ PUBLISHED
78
+ ARCHIVED
79
+ }
80
+
81
+ // ─── Models ─────────────────────────────────────────────────────────
82
+
83
+ model User {
84
+ id String @id @default(cuid())
85
+ email String @unique
86
+ name String
87
+ avatarUrl String?
88
+ role UserRole @default(AUTHOR)
89
+ passwordHash String?
90
+ totpSecret String?
91
+ totpEnabled Boolean @default(false)
92
+ // Last consumed TOTP time-step — enforces single-use so a captured code can't
93
+ // be replayed within its validity window (#565). Null until the first TOTP login.
94
+ lastTotpStep Int?
95
+ backupCodes Json?
96
+ authProvider String?
97
+ isActive Boolean @default(true)
98
+ isApproved Boolean @default(false)
99
+ emailVerified Boolean @default(false)
100
+ lastLoginAt DateTime?
101
+ createdAt DateTime @default(now())
102
+ updatedAt DateTime @updatedAt
103
+
104
+ // Access-control lifecycle (Users management subsystem). All additive and
105
+ // nullable/defaulted so existing rows migrate cleanly.
106
+ suspendedAt DateTime?
107
+ lockedAt DateTime?
108
+ forcePasswordReset Boolean @default(false)
109
+ mfaRequired Boolean @default(false)
110
+ failedLoginCount Int @default(0)
111
+ lastActiveAt DateTime?
112
+ invitedById String?
113
+ acceptedInviteAt DateTime?
114
+
115
+ sessions Session[]
116
+ oauthAccounts OAuthAccount[]
117
+ apiKeys ApiKey[]
118
+ documents Document[] @relation("CreatedDocuments")
119
+ updatedDocuments Document[] @relation("UpdatedDocuments")
120
+ versions Version[]
121
+ media Media[]
122
+ contentLocks ContentLock[]
123
+ notifications InAppNotification[]
124
+ contentTemplates ContentTemplate[]
125
+ workflowAssigned WorkflowState[]
126
+ passwordResetTokens PasswordResetToken[]
127
+
128
+ @@map("prenta_users")
129
+ }
130
+
131
+ model OAuthAccount {
132
+ id String @id @default(cuid())
133
+ userId String
134
+ provider String
135
+ providerAccountId String
136
+ accessToken String?
137
+ refreshToken String?
138
+ expiresAt DateTime?
139
+ createdAt DateTime @default(now())
140
+
141
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
142
+
143
+ @@unique([provider, providerAccountId])
144
+ @@map("prenta_oauth_accounts")
145
+ }
146
+
147
+ model Session {
148
+ id String @id @default(cuid())
149
+ userId String
150
+ token String @unique
151
+ expiresAt DateTime
152
+ revokedAt DateTime?
153
+ ipAddress String?
154
+ userAgent String?
155
+ fingerprintHash String?
156
+ createdAt DateTime @default(now())
157
+
158
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
159
+
160
+ @@index([userId])
161
+ @@index([expiresAt])
162
+ @@map("prenta_sessions")
163
+ }
164
+
165
+ model ApiKey {
166
+ id String @id @default(cuid())
167
+ name String
168
+ keyHash String @unique
169
+ keyPrefix String
170
+ userId String
171
+ scopes Json
172
+ ipRestrictions Json?
173
+ expiresAt DateTime?
174
+ lastUsedAt DateTime?
175
+ revokedAt DateTime?
176
+ createdAt DateTime @default(now())
177
+
178
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
179
+
180
+ @@map("prenta_api_keys")
181
+ }
182
+
183
+ model AuditLog {
184
+ id String @id @default(cuid())
185
+ event String
186
+ userId String?
187
+ ipAddress String?
188
+ userAgent String?
189
+ details Json?
190
+ timestamp DateTime @default(now())
191
+
192
+ @@index([event])
193
+ @@index([userId])
194
+ @@index([timestamp])
195
+ @@map("prenta_audit_logs")
196
+ }
197
+
198
+ model Folder {
199
+ id String @id @default(cuid())
200
+ name String
201
+ scope String
202
+ parentId String?
203
+ position Int @default(0)
204
+ createdAt DateTime @default(now())
205
+ updatedAt DateTime @updatedAt
206
+
207
+ parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: Cascade)
208
+ children Folder[] @relation("FolderTree")
209
+
210
+ documents Document[]
211
+ media Media[]
212
+
213
+ @@index([scope])
214
+ @@index([parentId])
215
+ @@map("prenta_folders")
216
+ }
217
+
218
+ model Document {
219
+ id String @id @default(cuid())
220
+ collection String
221
+ title String?
222
+ slug String?
223
+ data Json
224
+ status DocumentStatus @default(DRAFT)
225
+ locale String?
226
+ localeGroupId String?
227
+ createdById String
228
+ updatedById String
229
+ publishedAt DateTime?
230
+ scheduledAt DateTime?
231
+ scheduledUnpublishAt DateTime?
232
+ deletedAt DateTime?
233
+ plainText String?
234
+ contentHash String?
235
+ structuredData Json?
236
+ contentGraph Json?
237
+ siteId String?
238
+ templateId String?
239
+ workflowStage WorkflowStage @default(DRAFT)
240
+ reviewerId String?
241
+ reviewNote String?
242
+ folderId String?
243
+ createdAt DateTime @default(now())
244
+ updatedAt DateTime @updatedAt
245
+
246
+ createdBy User @relation("CreatedDocuments", fields: [createdById], references: [id])
247
+ updatedBy User @relation("UpdatedDocuments", fields: [updatedById], references: [id])
248
+ folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
249
+ versions Version[]
250
+ mediaUsages MediaUsage[]
251
+ contentLocks ContentLock[]
252
+ contentIssues ContentIssue[]
253
+ workflowState WorkflowState?
254
+
255
+ @@unique([collection, slug])
256
+ @@index([collection])
257
+ @@index([status])
258
+ @@index([deletedAt])
259
+ @@index([publishedAt])
260
+ @@index([folderId])
261
+ @@index([scheduledAt])
262
+ @@index([localeGroupId])
263
+ @@index([createdById])
264
+ @@map("prenta_documents")
265
+ }
266
+
267
+ model Version {
268
+ id String @id @default(cuid())
269
+ documentId String
270
+ data Json
271
+ changedById String
272
+ changeType ChangeType
273
+ createdAt DateTime @default(now())
274
+
275
+ document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
276
+ changedBy User @relation(fields: [changedById], references: [id])
277
+
278
+ @@index([documentId])
279
+ @@map("prenta_versions")
280
+ }
281
+
282
+ enum MediaStatus {
283
+ PROCESSING
284
+ READY
285
+ FAILED
286
+ }
287
+
288
+ model Media {
289
+ id String @id @default(cuid())
290
+ filename String
291
+ storageKey String @unique
292
+ mimeType String
293
+ fileSize Int
294
+ width Int?
295
+ height Int?
296
+ altText String?
297
+ title String?
298
+ uploadedById String
299
+ focalPointX Float?
300
+ focalPointY Float?
301
+ blurHash String?
302
+ aiAltText String?
303
+ aiTags Json?
304
+ aiDescription String?
305
+ folderId String?
306
+ createdAt DateTime @default(now())
307
+ updatedAt DateTime @updatedAt
308
+
309
+ // ── Media Center additions ──────────────────────────────────────
310
+ // All nullable / defaulted so they're safe to add to a populated DB
311
+ // with no backfill. SEO score/issues are intentionally NOT stored —
312
+ // they're derived on read by `calculateMediaSeoScore()` so they can
313
+ // never drift from the metadata below.
314
+ originalFileName String?
315
+ caption String?
316
+ description String?
317
+ focusKeyword String?
318
+ // WCAG decorative images use alt="" intentionally. Persisted so the
319
+ // media SEO scorer awards the alt weight instead of flagging missing-alt
320
+ // (#714). Cleared automatically when a non-empty alt is saved.
321
+ isDecorative Boolean @default(false)
322
+ tags Json?
323
+ // SHA-256 hex of the final stored bytes (post-optimize). Used to make
324
+ // re-uploads / seed re-runs idempotent instead of creating byte-identical
325
+ // twins (#718). Nullable so legacy rows need no backfill.
326
+ contentHash String?
327
+ // `storageKey` historically held the public URL; `publicUrl` splits
328
+ // that concern out. New uploads populate both; legacy rows keep the
329
+ // URL in `storageKey` and `mediaUrl()` still resolves it.
330
+ publicUrl String?
331
+ storageProvider String?
332
+ durationSeconds Float?
333
+ thumbnailUrl String?
334
+ responsiveVariants Json?
335
+ status MediaStatus @default(READY)
336
+ deletedAt DateTime?
337
+
338
+ uploadedBy User @relation(fields: [uploadedById], references: [id])
339
+ folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
340
+ mediaUsages MediaUsage[]
341
+
342
+ @@index([folderId])
343
+ @@index([deletedAt])
344
+ @@index([mimeType])
345
+ @@index([contentHash])
346
+ @@map("prenta_media")
347
+ }
348
+
349
+ model MediaUsage {
350
+ id String @id @default(cuid())
351
+ mediaId String
352
+ documentId String
353
+ fieldPath String
354
+ createdAt DateTime @default(now())
355
+
356
+ media Media @relation(fields: [mediaId], references: [id], onDelete: Cascade)
357
+ document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
358
+
359
+ @@unique([mediaId, documentId, fieldPath])
360
+ @@index([documentId])
361
+ @@map("prenta_media_usages")
362
+ }
363
+
364
+ model ContentLock {
365
+ id String @id @default(cuid())
366
+ documentId String
367
+ userId String
368
+ lockedAt DateTime @default(now())
369
+ expiresAt DateTime
370
+
371
+ document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
372
+ user User @relation(fields: [userId], references: [id])
373
+
374
+ @@map("prenta_content_locks")
375
+ }
376
+
377
+ model InAppNotification {
378
+ id String @id @default(cuid())
379
+ userId String
380
+ type String
381
+ title String
382
+ message String?
383
+ link String?
384
+ read Boolean @default(false)
385
+ createdAt DateTime @default(now())
386
+
387
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
388
+
389
+ @@map("prenta_notifications")
390
+ }
391
+
392
+ model ContentTemplate {
393
+ id String @id @default(cuid())
394
+ name String
395
+ collection String
396
+ templateData Json
397
+ createdById String
398
+ createdAt DateTime @default(now())
399
+ updatedAt DateTime @updatedAt
400
+
401
+ createdBy User @relation(fields: [createdById], references: [id])
402
+
403
+ @@map("prenta_content_templates")
404
+ }
405
+
406
+ model Site {
407
+ id String @id @default(cuid())
408
+ domain String @unique
409
+ name String
410
+ configOverrides Json?
411
+ isDefault Boolean @default(false)
412
+ createdAt DateTime @default(now())
413
+ updatedAt DateTime @updatedAt
414
+
415
+ @@map("prenta_sites")
416
+ }
417
+
418
+ model WorkflowState {
419
+ id String @id @default(cuid())
420
+ documentId String @unique
421
+ currentStep Int @default(0)
422
+ assignedToId String?
423
+ status String @default("pending")
424
+ definition Json
425
+ createdAt DateTime @default(now())
426
+ updatedAt DateTime @updatedAt
427
+
428
+ document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
429
+ assignedTo User? @relation(fields: [assignedToId], references: [id])
430
+
431
+ @@map("prenta_workflow_states")
432
+ }
433
+
434
+ model Redirect {
435
+ id String @id @default(cuid())
436
+ source String
437
+ destination String
438
+ statusCode Int @default(301)
439
+ isRegex Boolean @default(false)
440
+ notes String?
441
+ // Runtime + management metadata (added in 0009_seo). All defaulted so the
442
+ // migration is safe to apply to a populated table with no backfill.
443
+ enabled Boolean @default(true)
444
+ priority Int @default(0)
445
+ preserveQuery Boolean @default(false)
446
+ // "manual" | "import" | "ai-404-recovery" | "chain-flatten"
447
+ source_kind String @default("manual")
448
+ hits Int @default(0)
449
+ lastHitAt DateTime?
450
+ createdById String?
451
+ createdAt DateTime @default(now())
452
+ updatedAt DateTime @updatedAt
453
+
454
+ @@unique([source])
455
+ @@index([enabled])
456
+ @@index([lastHitAt])
457
+ @@map("prenta_redirects")
458
+ }
459
+
460
+ // ─── SEO module (0009_seo) ──────────────────────────────────────────
461
+
462
+ enum SeoAuditStatus {
463
+ RUNNING
464
+ COMPLETED
465
+ FAILED
466
+ }
467
+
468
+ enum SeoIssueSeverity {
469
+ critical
470
+ warning
471
+ info
472
+ }
473
+
474
+ enum SeoIssueStatus {
475
+ open
476
+ ignored
477
+ resolved
478
+ recurring
479
+ }
480
+
481
+ enum SeoIssueCategory {
482
+ content
483
+ technical
484
+ redirects
485
+ media
486
+ performance
487
+ schema
488
+ social
489
+ indexing
490
+ }
491
+
492
+ /// One execution of the SEO audit engine. Holds a snapshot of the site
493
+ /// score and issue counts so the Audit tab can show run history without
494
+ /// recomputing. Issues belong to a run via SEOIssue.auditRunId.
495
+ model SeoAuditRun {
496
+ id String @id @default(cuid())
497
+ status SeoAuditStatus @default(RUNNING)
498
+ scope String @default("full")
499
+ startedById String?
500
+ startedAt DateTime @default(now())
501
+ finishedAt DateTime?
502
+ siteScoreBefore Int?
503
+ siteScoreAfter Int?
504
+ pagesScanned Int @default(0)
505
+ issuesFound Int @default(0)
506
+ criticalCount Int @default(0)
507
+ warningCount Int @default(0)
508
+ infoCount Int @default(0)
509
+ errorMessage String?
510
+ metadata Json?
511
+
512
+ issues SeoIssue[]
513
+
514
+ @@index([status])
515
+ @@index([startedAt])
516
+ @@map("prenta_seo_audit_runs")
517
+ }
518
+
519
+ /// A single SEO problem detected by an audit run. `issueKey` is a stable
520
+ /// hash of (type + entityId/url) used to carry status across runs so a
521
+ /// resolved/ignored issue stays resolved/ignored when re-detected.
522
+ model SeoIssue {
523
+ id String @id @default(cuid())
524
+ auditRunId String?
525
+ issueKey String
526
+ entityType String?
527
+ entityId String?
528
+ entityTitle String?
529
+ url String?
530
+ severity SeoIssueSeverity
531
+ category SeoIssueCategory
532
+ type String
533
+ title String
534
+ description String?
535
+ impact String?
536
+ recommendation String?
537
+ status SeoIssueStatus @default(open)
538
+ fixable Boolean @default(false)
539
+ fixActionType String?
540
+ relatedEntityIds Json?
541
+ source String @default("audit")
542
+ firstDetectedAt DateTime @default(now())
543
+ lastDetectedAt DateTime @default(now())
544
+ resolvedAt DateTime?
545
+ resolvedById String?
546
+ metadata Json?
547
+
548
+ auditRun SeoAuditRun? @relation(fields: [auditRunId], references: [id], onDelete: SetNull)
549
+
550
+ @@index([auditRunId])
551
+ @@index([status])
552
+ @@index([severity])
553
+ @@index([category])
554
+ @@index([issueKey])
555
+ @@map("prenta_seo_issues")
556
+ }
557
+
558
+ /// A 404 hit observed by the redirect middleware. Aggregated per path so
559
+ /// the Redirects tab can surface "broken URLs still getting traffic".
560
+ model Redirect404Hit {
561
+ id String @id @default(cuid())
562
+ path String
563
+ hits Int @default(1)
564
+ referrer String?
565
+ resolvedAt DateTime?
566
+ firstSeenAt DateTime @default(now())
567
+ lastSeenAt DateTime @default(now())
568
+
569
+ @@unique([path])
570
+ @@index([lastSeenAt])
571
+ @@map("prenta_redirect_404_hits")
572
+ }
573
+
574
+ /// Aggregated AI-crawler activity for the admin "AI crawler activity" panel.
575
+ /// One row per (bot token, surface), upserted on each hit so the table stays
576
+ /// bounded (~known bots × surfaces) and needs no periodic pruning.
577
+ /// `surface` ∈ "llms" | "llms-full" | "md" | "honeypot".
578
+ /// `category` mirrors the ai-bots taxonomy: "training" | "retrieval" | "other".
579
+ model AiCrawlerHit {
580
+ id String @id @default(cuid())
581
+ bot String
582
+ surface String
583
+ category String @default("other")
584
+ hits Int @default(1)
585
+ firstSeenAt DateTime @default(now())
586
+ lastSeenAt DateTime @default(now())
587
+
588
+ @@unique([bot, surface])
589
+ @@index([lastSeenAt])
590
+ @@map("prenta_ai_crawler_hits")
591
+ }
592
+
593
+ /// An AI/heuristic suggestion for a 404 destination. Confidence-scored and
594
+ /// never auto-applied below the configured threshold.
595
+ model RedirectSuggestion {
596
+ id String @id @default(cuid())
597
+ fromPath String
598
+ toPath String
599
+ confidence Int @default(0)
600
+ reason String?
601
+ // "open" | "accepted" | "dismissed"
602
+ status String @default("open")
603
+ source String @default("ai-404-recovery")
604
+ metadata Json?
605
+ createdAt DateTime @default(now())
606
+ updatedAt DateTime @updatedAt
607
+
608
+ @@index([status])
609
+ @@index([fromPath])
610
+ @@map("prenta_redirect_suggestions")
611
+ }
612
+
613
+ // A versioned snapshot of a form's field schema. Saving a form's schema in
614
+ // the admin creates a new row here; submissions reference the version they
615
+ // were captured against so historical entries always render with the fields
616
+ // that existed at submit time, even after the live form schema changes.
617
+ model FormSchemaVersion {
618
+ id String @id @default(cuid())
619
+ formId String
620
+ version Int
621
+ fields Json
622
+ notes String?
623
+ createdById String?
624
+ createdAt DateTime @default(now())
625
+
626
+ @@unique([formId, version])
627
+ @@index([formId])
628
+ @@map("prenta_form_schema_versions")
629
+ }
630
+
631
+ model FormSubmission {
632
+ id String @id @default(cuid())
633
+ // Per-instance sequential number shown in the UI (e.g. #00001). Assigned by
634
+ // the submission service inside a transaction; nullable for legacy rows.
635
+ entryNumber Int?
636
+ formId String
637
+ schemaVersionId String?
638
+ data Json
639
+ // Server-derived, type-coerced view of `data` (e.g. checkbox -> bool,
640
+ // number -> number) used for export/search. `data` keeps the raw payload.
641
+ normalizedData Json?
642
+ attribution Json?
643
+ senderName String?
644
+ senderEmail String?
645
+ senderPhone String?
646
+ preview String?
647
+ // Legacy free-form status ('new'); superseded by the booleans below but
648
+ // retained so existing rows/integrations keep working.
649
+ status String @default("new")
650
+ unread Boolean @default(true)
651
+ starred Boolean @default(false)
652
+ archived Boolean @default(false)
653
+ spamStatus String @default("clean")
654
+ ipAddressHash String?
655
+ userAgent String?
656
+ referrer String?
657
+ pageUrl String?
658
+ utmSource String?
659
+ utmMedium String?
660
+ utmCampaign String?
661
+ submittedAt DateTime @default(now())
662
+ readAt DateTime?
663
+ archivedAt DateTime?
664
+ createdAt DateTime @default(now())
665
+ updatedAt DateTime @updatedAt
666
+
667
+ files FormSubmissionFile[]
668
+ deliveries FormDeliveryAttempt[]
669
+
670
+ @@index([formId])
671
+ @@index([status])
672
+ @@index([unread])
673
+ @@index([starred])
674
+ @@index([archived])
675
+ @@index([spamStatus])
676
+ @@index([submittedAt])
677
+ @@index([senderEmail])
678
+ @@map("prenta_form_submissions")
679
+ }
680
+
681
+ // File attachments for a submission. Files are stored via the configured
682
+ // storage adapter (never inline in the DB); this row tracks metadata and the
683
+ // storage key needed to mint a secure download link.
684
+ model FormSubmissionFile {
685
+ id String @id @default(cuid())
686
+ submissionId String
687
+ fieldKey String
688
+ mediaAssetId String?
689
+ fileName String
690
+ mimeType String
691
+ sizeBytes Int
692
+ storageKey String
693
+ publicUrl String?
694
+ secureDownloadUrl String?
695
+ createdAt DateTime @default(now())
696
+
697
+ submission FormSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
698
+
699
+ @@index([submissionId])
700
+ @@map("prenta_form_submission_files")
701
+ }
702
+
703
+ // Per-form outbound webhook configuration. Distinct from the global
704
+ // WebhookEndpoint registry so form owners can wire delivery without admin
705
+ // access to site-wide webhooks.
706
+ model FormWebhook {
707
+ id String @id @default(cuid())
708
+ formId String
709
+ url String
710
+ secret String
711
+ /// Previous signing secret (encrypted) kept during rotation grace (#679).
712
+ previousSecret String?
713
+ previousSecretValidUntil DateTime?
714
+ enabled Boolean @default(true)
715
+ events Json
716
+ retryCount Int @default(0)
717
+ lastDeliveryAt DateTime?
718
+ lastDeliveryStatus String?
719
+ createdAt DateTime @default(now())
720
+ updatedAt DateTime @updatedAt
721
+
722
+ @@index([formId])
723
+ @@map("prenta_form_webhooks")
724
+ }
725
+
726
+ /// Per-submission notify/webhook/email delivery attempts (#675).
727
+ /// Fail-open: the lead is always persisted; this table tracks best-effort fan-out.
728
+ model FormDeliveryAttempt {
729
+ id String @id @default(cuid())
730
+ submissionId String
731
+ formId String
732
+ /// email_notify | email_autoresponder | form_webhook
733
+ channel String
734
+ /// Recipient email or webhook URL (never secrets).
735
+ destination String
736
+ webhookId String?
737
+ /// pending | retrying | success | failed | skipped
738
+ status String @default("pending")
739
+ attempts Int @default(0)
740
+ maxAttempts Int @default(3)
741
+ lastAttemptAt DateTime?
742
+ nextRetryAt DateTime?
743
+ error String?
744
+ responseStatus Int?
745
+ skippedReason String?
746
+ /// Webhook payload (or email meta) for retry without re-submitting.
747
+ payload Json?
748
+ /// When an operator alert email was sent for a permanent failure.
749
+ alertedAt DateTime?
750
+ createdAt DateTime @default(now())
751
+ updatedAt DateTime @updatedAt
752
+
753
+ submission FormSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
754
+
755
+ @@index([formId, status])
756
+ @@index([submissionId])
757
+ @@index([status, nextRetryAt])
758
+ @@map("prenta_form_delivery_attempts")
759
+ }
760
+
761
+ model BackupRecord {
762
+ id String @id @default(cuid())
763
+ filename String
764
+ storageKey String
765
+ sizeBytes Int
766
+ type String
767
+ metadata Json?
768
+ createdAt DateTime @default(now())
769
+
770
+ @@map("prenta_backup_records")
771
+ }
772
+
773
+ model WebhookEndpoint {
774
+ id String @id @default(cuid())
775
+ url String
776
+ events Json
777
+ secret String
778
+ /// Previous signing secret (encrypted) kept during rotation grace (#679).
779
+ previousSecret String?
780
+ previousSecretValidUntil DateTime?
781
+ active Boolean @default(true)
782
+ name String?
783
+ createdAt DateTime @default(now())
784
+ updatedAt DateTime @updatedAt
785
+
786
+ deliveries WebhookDeliveryLog[]
787
+
788
+ @@map("prenta_webhook_endpoints")
789
+ }
790
+
791
+ model WebhookDeliveryLog {
792
+ id String @id @default(cuid())
793
+ endpointId String
794
+ event String
795
+ payload Json
796
+ status String @default("pending")
797
+ attempts Int @default(0)
798
+ maxAttempts Int @default(3)
799
+ lastAttemptAt DateTime?
800
+ nextRetryAt DateTime?
801
+ responseStatus Int?
802
+ responseBody String?
803
+ createdAt DateTime @default(now())
804
+
805
+ endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade)
806
+
807
+ @@index([endpointId])
808
+ @@index([status])
809
+ @@index([nextRetryAt])
810
+ @@map("prenta_webhook_deliveries")
811
+ }
812
+
813
+ model PasswordResetToken {
814
+ id String @id @default(cuid())
815
+ userId String
816
+ tokenHash String
817
+ expiresAt DateTime
818
+ usedAt DateTime?
819
+ createdAt DateTime @default(now())
820
+
821
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
822
+
823
+ @@index([tokenHash])
824
+ @@index([userId])
825
+ @@map("prenta_password_reset_tokens")
826
+ }
827
+
828
+ model Invite {
829
+ id String @id @default(cuid())
830
+ email String
831
+ role UserRole
832
+ status InviteStatus @default(PENDING)
833
+ tokenHash String
834
+ message String?
835
+ // Stored as a bare id (not an FK relation) to mirror AuditLog: an inviter
836
+ // being removed must never cascade-delete the invite history.
837
+ invitedByUserId String?
838
+ acceptedUserId String?
839
+ expiresAt DateTime
840
+ acceptedAt DateTime?
841
+ cancelledAt DateTime?
842
+ createdAt DateTime @default(now())
843
+ updatedAt DateTime @updatedAt
844
+
845
+ @@index([email])
846
+ @@index([tokenHash])
847
+ @@index([status])
848
+ @@map("prenta_invites")
849
+ }
850
+
851
+ model ScriptTag {
852
+ id String @id @default(cuid())
853
+ name String
854
+ code String @db.Text
855
+ placement String
856
+ scope String
857
+ targetPaths String[]
858
+ priority Int @default(100)
859
+ enabled Boolean @default(true)
860
+ /// Inject `async` into external <script src> tags so they don't block render.
861
+ loadAsync Boolean @default(false)
862
+ /// Skip this tag when the page is rendered inside CMS preview mode.
863
+ disableInPreview Boolean @default(true)
864
+ /// Cookie-consent category gate: none | analytics | marketing | functional | preferences.
865
+ consentCategory String @default("none")
866
+ createdAt DateTime @default(now())
867
+ updatedAt DateTime @updatedAt
868
+
869
+ @@index([enabled])
870
+ @@index([placement])
871
+ @@map("prenta_script_tags")
872
+ }
873
+
874
+ model PageTemplate {
875
+ id String @id @default(cuid())
876
+ name String
877
+ description String?
878
+ category String @default("content")
879
+ tree Json
880
+ thumbnail String?
881
+ builtIn Boolean @default(false)
882
+ createdAt DateTime @default(now())
883
+ updatedAt DateTime @updatedAt
884
+
885
+ @@index([category])
886
+ @@index([builtIn])
887
+ @@map("prenta_page_templates")
888
+ }
889
+
890
+ model SavedSection {
891
+ id String @id @default(cuid())
892
+ name String
893
+ description String?
894
+ category String @default("content")
895
+ tree Json
896
+ thumbnail String?
897
+ usageCount Int @default(0)
898
+ createdAt DateTime @default(now())
899
+ updatedAt DateTime @updatedAt
900
+
901
+ @@index([category])
902
+ @@map("prenta_saved_sections")
903
+ }
904
+
905
+ // ─── Dashboard metrics ─────────────────────────────────────────────────────
906
+
907
+ /// Per-minute pre-aggregated request counters that power the
908
+ /// "Delivery API" dashboard tiles (request volume, error rate,
909
+ /// average latency, p95). One row per (bucketStart × route × statusBucket).
910
+ ///
911
+ /// Why pre-aggregated minute buckets and not per-request rows:
912
+ /// - 24h of traffic at 10 req/s on 20 routes ≈ 17M raw rows; the
913
+ /// equivalent minute-bucketed table is ≤ 28,800 rows (1440 × 20).
914
+ /// - Vercel Functions are concurrency-bounded; an O(1) UPSERT per
915
+ /// request is cheaper than O(write+query) per dashboard load.
916
+ /// - 7-day retention is enforced by the
917
+ /// `/api/cms/cron/api-metrics-prune` cron, so the table stays
918
+ /// bounded.
919
+ ///
920
+ /// `latencyHistogram` is a fixed 12-bucket log-scale array (ms):
921
+ /// [<1, 1-2, 2-4, 4-8, 8-16, 16-32, 32-64, 64-128, 128-256,
922
+ /// 256-512, 512-1024, >1024]
923
+ /// Cumulative-sum to estimate p95 with ~10% accuracy without
924
+ /// per-request samples. The `delivery-stats` reader merges
925
+ /// histograms across buckets before estimating.
926
+ model ApiRequestMetric {
927
+ id String @id @default(cuid())
928
+ /// UTC minute boundary (`floor(time, '1 minute')`). The unique
929
+ /// constraint on `(bucketStart, route, statusBucket)` lets the
930
+ /// request logger UPSERT in one round-trip per request.
931
+ bucketStart DateTime
932
+ /// Normalised request route (e.g. `/collections/posts` — never
933
+ /// the per-document `/collections/posts/abc123`). Normalisation
934
+ /// happens in `instrumentation.ts` so the cardinality stays
935
+ /// bounded.
936
+ route String
937
+ /// HTTP status family — one of `2xx`, `3xx`, `4xx`, `5xx`,
938
+ /// `other`. Storing the bucket rather than the raw status lets
939
+ /// us answer "error rate?" with a single GROUP BY.
940
+ statusBucket String
941
+ count Int @default(0)
942
+ /// Sum of `responseMs` across `count` requests. Average latency
943
+ /// = `sum(sumLatencyMs) / sum(count)` over the window.
944
+ sumLatencyMs Float @default(0)
945
+ /// Max single-request latency in this bucket. Useful as a
946
+ /// fast-path tail-latency indicator and as a sanity check on
947
+ /// the histogram estimator.
948
+ maxLatencyMs Float @default(0)
949
+ /// 12-bucket log-scale histogram (see model doc). Stored as
950
+ /// `Json` so the schema doesn't have to change when we revise
951
+ /// bucket boundaries.
952
+ latencyHistogram Json
953
+ updatedAt DateTime @updatedAt
954
+
955
+ @@unique([bucketStart, route, statusBucket])
956
+ @@index([bucketStart])
957
+ @@map("prenta_api_request_metrics")
958
+ }
959
+
960
+ /// Persisted content-quality issues surfaced by the nightly
961
+ /// content-health scan. The Dashboard's "Content Health" card
962
+ /// aggregates these rows by `type` to produce the issue list.
963
+ ///
964
+ /// Upsert key is `(documentId, type)` so the scanner is
965
+ /// idempotent — re-running yields the same row count, and a row
966
+ /// "self-resolves" by being absent from the next scan (the
967
+ /// scanner uses a `firstScanAt`-watermark + delete pattern).
968
+ model ContentIssue {
969
+ id String @id @default(cuid())
970
+ documentId String
971
+ type ContentIssueType
972
+ /// Per-issue count (e.g. 5 alt-less images = `count: 5` even
973
+ /// though it's 1 row). Lets the dashboard show real numbers
974
+ /// without re-scanning the document HTML on each page load.
975
+ count Int @default(1)
976
+ /// Type-specific payload — the broken URL, the page's
977
+ /// `updatedAt`, the offending image src, etc. JSON so each
978
+ /// issue type can carry its own shape without schema churn.
979
+ details Json?
980
+ firstSeenAt DateTime @default(now())
981
+ lastSeenAt DateTime @default(now())
982
+
983
+ document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
984
+
985
+ @@unique([documentId, type])
986
+ @@index([type])
987
+ @@index([documentId])
988
+ @@map("prenta_content_issues")
989
+ }