@actuate-media/cms-core 0.25.1 → 0.26.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 (33) hide show
  1. package/dist/__tests__/api/stats-routes.test.d.ts +2 -0
  2. package/dist/__tests__/api/stats-routes.test.d.ts.map +1 -0
  3. package/dist/__tests__/api/stats-routes.test.js +251 -0
  4. package/dist/__tests__/api/stats-routes.test.js.map +1 -0
  5. package/dist/__tests__/content-health/scanner.test.d.ts +2 -0
  6. package/dist/__tests__/content-health/scanner.test.d.ts.map +1 -0
  7. package/dist/__tests__/content-health/scanner.test.js +151 -0
  8. package/dist/__tests__/content-health/scanner.test.js.map +1 -0
  9. package/dist/__tests__/diagnostics/api-metrics.test.d.ts +2 -0
  10. package/dist/__tests__/diagnostics/api-metrics.test.d.ts.map +1 -0
  11. package/dist/__tests__/diagnostics/api-metrics.test.js +153 -0
  12. package/dist/__tests__/diagnostics/api-metrics.test.js.map +1 -0
  13. package/dist/api/handler-factory.d.ts.map +1 -1
  14. package/dist/api/handler-factory.js +31 -1
  15. package/dist/api/handler-factory.js.map +1 -1
  16. package/dist/api/handlers.d.ts.map +1 -1
  17. package/dist/api/handlers.js +263 -0
  18. package/dist/api/handlers.js.map +1 -1
  19. package/dist/content-health/cron.d.ts +67 -0
  20. package/dist/content-health/cron.d.ts.map +1 -0
  21. package/dist/content-health/cron.js +167 -0
  22. package/dist/content-health/cron.js.map +1 -0
  23. package/dist/content-health/scanner.d.ts +118 -0
  24. package/dist/content-health/scanner.d.ts.map +1 -0
  25. package/dist/content-health/scanner.js +263 -0
  26. package/dist/content-health/scanner.js.map +1 -0
  27. package/dist/diagnostics/api-metrics.d.ts +135 -0
  28. package/dist/diagnostics/api-metrics.d.ts.map +1 -0
  29. package/dist/diagnostics/api-metrics.js +374 -0
  30. package/dist/diagnostics/api-metrics.js.map +1 -0
  31. package/package.json +1 -1
  32. package/prisma/migrations/0007_dashboard_metrics/migration.sql +74 -0
  33. package/prisma/schema.prisma +180 -73
@@ -1,6 +1,6 @@
1
1
  generator client {
2
- provider = "prisma-client"
3
- output = "../generated"
2
+ provider = "prisma-client"
3
+ output = "../generated"
4
4
  // `relationJoins` is the Preview-feature flag that makes
5
5
  // `relationLoadStrategy: 'join'` a recognised argument on
6
6
  // findUnique / findMany / findFirst. Without it the option is
@@ -73,6 +73,26 @@ enum AuditEvent {
73
73
  import_completed
74
74
  }
75
75
 
76
+ enum ContentIssueType {
77
+ // Document is published but missing a `metaDescription` /
78
+ // `seoDescription` field — the dashboard surfaces these so editors
79
+ // can fix the SERP snippet before a competitor outranks them.
80
+ MISSING_META_DESCRIPTION
81
+ // Document HTML contains <img> tags without alt attributes. Hurts
82
+ // both a11y and SEO. Counted per *document* not per image — so a
83
+ // page with 5 alt-less images is 1 row, not 5, with `details.count`
84
+ // capturing the per-document count.
85
+ MISSING_ALT_TEXT
86
+ // Internal link (same-origin URL or relative path) returned 404 /
87
+ // 410 / DNS-failure when the link checker probed it. `details.url`
88
+ // holds the offending URL.
89
+ BROKEN_INTERNAL_LINK
90
+ // Published document not touched in >90 days. Encourages refresh
91
+ // of stale content — particularly important for "best of YEAR"
92
+ // posts that decay rapidly.
93
+ OUTDATED_CONTENT
94
+ }
95
+
76
96
  enum NotificationType {
77
97
  DOCUMENT_PUBLISHED
78
98
  DOCUMENT_ASSIGNED
@@ -143,31 +163,31 @@ model User {
143
163
  createdAt DateTime @default(now())
144
164
  updatedAt DateTime @updatedAt
145
165
 
146
- sessions Session[]
147
- oauthAccounts OAuthAccount[]
148
- apiKeys ApiKey[]
149
- documents Document[] @relation("CreatedDocuments")
150
- updatedDocuments Document[] @relation("UpdatedDocuments")
151
- versions Version[]
152
- media Media[]
153
- contentLocks ContentLock[]
154
- notifications InAppNotification[]
155
- contentTemplates ContentTemplate[]
156
- workflowAssigned WorkflowState[]
157
- passwordResetTokens PasswordResetToken[]
166
+ sessions Session[]
167
+ oauthAccounts OAuthAccount[]
168
+ apiKeys ApiKey[]
169
+ documents Document[] @relation("CreatedDocuments")
170
+ updatedDocuments Document[] @relation("UpdatedDocuments")
171
+ versions Version[]
172
+ media Media[]
173
+ contentLocks ContentLock[]
174
+ notifications InAppNotification[]
175
+ contentTemplates ContentTemplate[]
176
+ workflowAssigned WorkflowState[]
177
+ passwordResetTokens PasswordResetToken[]
158
178
 
159
179
  @@map("actuate_users")
160
180
  }
161
181
 
162
182
  model OAuthAccount {
163
- id String @id @default(cuid())
183
+ id String @id @default(cuid())
164
184
  userId String
165
185
  provider String
166
186
  providerAccountId String
167
187
  accessToken String?
168
188
  refreshToken String?
169
189
  expiresAt DateTime?
170
- createdAt DateTime @default(now())
190
+ createdAt DateTime @default(now())
171
191
 
172
192
  user User @relation(fields: [userId], references: [id], onDelete: Cascade)
173
193
 
@@ -247,40 +267,41 @@ model Folder {
247
267
  }
248
268
 
249
269
  model Document {
250
- id String @id @default(cuid())
251
- collection String
252
- title String?
253
- slug String?
254
- data Json
255
- status DocumentStatus @default(DRAFT)
256
- locale String?
257
- localeGroupId String?
258
- createdById String
259
- updatedById String
260
- publishedAt DateTime?
261
- scheduledAt DateTime?
262
- scheduledUnpublishAt DateTime?
263
- deletedAt DateTime?
264
- searchVector String?
265
- plainText String?
266
- contentHash String?
267
- structuredData Json?
268
- contentGraph Json?
269
- siteId String?
270
- templateId String?
271
- workflowStage WorkflowStage @default(DRAFT)
272
- reviewerId String?
273
- reviewNote String?
274
- folderId String?
275
- createdAt DateTime @default(now())
276
- updatedAt DateTime @updatedAt
277
-
278
- createdBy User @relation("CreatedDocuments", fields: [createdById], references: [id])
279
- updatedBy User @relation("UpdatedDocuments", fields: [updatedById], references: [id])
280
- folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
281
- versions Version[]
282
- mediaUsages MediaUsage[]
283
- contentLocks ContentLock[]
270
+ id String @id @default(cuid())
271
+ collection String
272
+ title String?
273
+ slug String?
274
+ data Json
275
+ status DocumentStatus @default(DRAFT)
276
+ locale String?
277
+ localeGroupId String?
278
+ createdById String
279
+ updatedById String
280
+ publishedAt DateTime?
281
+ scheduledAt DateTime?
282
+ scheduledUnpublishAt DateTime?
283
+ deletedAt DateTime?
284
+ searchVector String?
285
+ plainText String?
286
+ contentHash String?
287
+ structuredData Json?
288
+ contentGraph Json?
289
+ siteId String?
290
+ templateId String?
291
+ workflowStage WorkflowStage @default(DRAFT)
292
+ reviewerId String?
293
+ reviewNote String?
294
+ folderId String?
295
+ createdAt DateTime @default(now())
296
+ updatedAt DateTime @updatedAt
297
+
298
+ createdBy User @relation("CreatedDocuments", fields: [createdById], references: [id])
299
+ updatedBy User @relation("UpdatedDocuments", fields: [updatedById], references: [id])
300
+ folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
301
+ versions Version[]
302
+ mediaUsages MediaUsage[]
303
+ contentLocks ContentLock[]
304
+ contentIssues ContentIssue[]
284
305
 
285
306
  @@unique([collection, slug])
286
307
  @@index([collection])
@@ -310,25 +331,25 @@ model Version {
310
331
  }
311
332
 
312
333
  model Media {
313
- id String @id @default(cuid())
314
- filename String
315
- storageKey String @unique
316
- mimeType String
317
- fileSize Int
318
- width Int?
319
- height Int?
320
- altText String?
321
- title String?
322
- uploadedById String
323
- focalPointX Float?
324
- focalPointY Float?
325
- blurHash String?
326
- aiAltText String?
327
- aiTags Json?
334
+ id String @id @default(cuid())
335
+ filename String
336
+ storageKey String @unique
337
+ mimeType String
338
+ fileSize Int
339
+ width Int?
340
+ height Int?
341
+ altText String?
342
+ title String?
343
+ uploadedById String
344
+ focalPointX Float?
345
+ focalPointY Float?
346
+ blurHash String?
347
+ aiAltText String?
348
+ aiTags Json?
328
349
  aiDescription String?
329
- folderId String?
330
- createdAt DateTime @default(now())
331
- updatedAt DateTime @updatedAt
350
+ folderId String?
351
+ createdAt DateTime @default(now())
352
+ updatedAt DateTime @updatedAt
332
353
 
333
354
  uploadedBy User @relation(fields: [uploadedById], references: [id])
334
355
  folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
@@ -478,18 +499,18 @@ model WebhookEndpoint {
478
499
  }
479
500
 
480
501
  model WebhookDeliveryLog {
481
- id String @id @default(cuid())
502
+ id String @id @default(cuid())
482
503
  endpointId String
483
504
  event String
484
505
  payload Json
485
- status String @default("pending")
486
- attempts Int @default(0)
487
- maxAttempts Int @default(3)
506
+ status String @default("pending")
507
+ attempts Int @default(0)
508
+ maxAttempts Int @default(3)
488
509
  lastAttemptAt DateTime?
489
510
  nextRetryAt DateTime?
490
511
  responseStatus Int?
491
512
  responseBody String?
492
- createdAt DateTime @default(now())
513
+ createdAt DateTime @default(now())
493
514
 
494
515
  endpoint WebhookEndpoint @relation(fields: [endpointId], references: [id], onDelete: Cascade)
495
516
 
@@ -561,3 +582,89 @@ model SavedSection {
561
582
  @@index([category])
562
583
  @@map("actuate_saved_sections")
563
584
  }
585
+
586
+ // ─── Dashboard metrics ─────────────────────────────────────────────────────
587
+
588
+ /// Per-minute pre-aggregated request counters that power the
589
+ /// "Delivery API" dashboard tiles (request volume, error rate,
590
+ /// average latency, p95). One row per (bucketStart × route × statusBucket).
591
+ ///
592
+ /// Why pre-aggregated minute buckets and not per-request rows:
593
+ /// - 24h of traffic at 10 req/s on 20 routes ≈ 17M raw rows; the
594
+ /// equivalent minute-bucketed table is ≤ 28,800 rows (1440 × 20).
595
+ /// - Vercel Functions are concurrency-bounded; an O(1) UPSERT per
596
+ /// request is cheaper than O(write+query) per dashboard load.
597
+ /// - 7-day retention is enforced by the
598
+ /// `/api/cms/cron/api-metrics-prune` cron, so the table stays
599
+ /// bounded.
600
+ ///
601
+ /// `latencyHistogram` is a fixed 12-bucket log-scale array (ms):
602
+ /// [<1, 1-2, 2-4, 4-8, 8-16, 16-32, 32-64, 64-128, 128-256,
603
+ /// 256-512, 512-1024, >1024]
604
+ /// Cumulative-sum to estimate p95 with ~10% accuracy without
605
+ /// per-request samples. The `delivery-stats` reader merges
606
+ /// histograms across buckets before estimating.
607
+ model ApiRequestMetric {
608
+ id String @id @default(cuid())
609
+ /// UTC minute boundary (`floor(time, '1 minute')`). The unique
610
+ /// constraint on `(bucketStart, route, statusBucket)` lets the
611
+ /// request logger UPSERT in one round-trip per request.
612
+ bucketStart DateTime
613
+ /// Normalised request route (e.g. `/collections/posts` — never
614
+ /// the per-document `/collections/posts/abc123`). Normalisation
615
+ /// happens in `instrumentation.ts` so the cardinality stays
616
+ /// bounded.
617
+ route String
618
+ /// HTTP status family — one of `2xx`, `3xx`, `4xx`, `5xx`,
619
+ /// `other`. Storing the bucket rather than the raw status lets
620
+ /// us answer "error rate?" with a single GROUP BY.
621
+ statusBucket String
622
+ count Int @default(0)
623
+ /// Sum of `responseMs` across `count` requests. Average latency
624
+ /// = `sum(sumLatencyMs) / sum(count)` over the window.
625
+ sumLatencyMs Float @default(0)
626
+ /// Max single-request latency in this bucket. Useful as a
627
+ /// fast-path tail-latency indicator and as a sanity check on
628
+ /// the histogram estimator.
629
+ maxLatencyMs Float @default(0)
630
+ /// 12-bucket log-scale histogram (see model doc). Stored as
631
+ /// `Json` so the schema doesn't have to change when we revise
632
+ /// bucket boundaries.
633
+ latencyHistogram Json
634
+ updatedAt DateTime @updatedAt
635
+
636
+ @@unique([bucketStart, route, statusBucket])
637
+ @@index([bucketStart])
638
+ @@map("actuate_api_request_metrics")
639
+ }
640
+
641
+ /// Persisted content-quality issues surfaced by the nightly
642
+ /// content-health scan. The Dashboard's "Content Health" card
643
+ /// aggregates these rows by `type` to produce the issue list.
644
+ ///
645
+ /// Upsert key is `(documentId, type)` so the scanner is
646
+ /// idempotent — re-running yields the same row count, and a row
647
+ /// "self-resolves" by being absent from the next scan (the
648
+ /// scanner uses a `firstScanAt`-watermark + delete pattern).
649
+ model ContentIssue {
650
+ id String @id @default(cuid())
651
+ documentId String
652
+ type ContentIssueType
653
+ /// Per-issue count (e.g. 5 alt-less images = `count: 5` even
654
+ /// though it's 1 row). Lets the dashboard show real numbers
655
+ /// without re-scanning the document HTML on each page load.
656
+ count Int @default(1)
657
+ /// Type-specific payload — the broken URL, the page's
658
+ /// `updatedAt`, the offending image src, etc. JSON so each
659
+ /// issue type can carry its own shape without schema churn.
660
+ details Json?
661
+ firstSeenAt DateTime @default(now())
662
+ lastSeenAt DateTime @default(now())
663
+
664
+ document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
665
+
666
+ @@unique([documentId, type])
667
+ @@index([type])
668
+ @@index([documentId])
669
+ @@map("actuate_content_issues")
670
+ }