@growth-labs/cms 0.5.29 → 0.5.31
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/dist/engine/foundry-dispatch.d.ts +7 -2
- package/dist/engine/foundry-dispatch.d.ts.map +1 -1
- package/dist/engine/foundry-dispatch.js +24 -6
- package/dist/engine/foundry-dispatch.js.map +1 -1
- package/dist/engine/fronts-publish-intent.d.ts +174 -0
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -0
- package/dist/engine/fronts-publish-intent.js +352 -0
- package/dist/engine/fronts-publish-intent.js.map +1 -0
- package/dist/engine/fronts-publish.d.ts +80 -0
- package/dist/engine/fronts-publish.d.ts.map +1 -0
- package/dist/engine/fronts-publish.js +286 -0
- package/dist/engine/fronts-publish.js.map +1 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +10 -0
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication-continuity.d.ts +77 -0
- package/dist/engine/publication-continuity.d.ts.map +1 -0
- package/dist/engine/publication-continuity.js +94 -0
- package/dist/engine/publication-continuity.js.map +1 -0
- package/dist/engine/publication-receipt/canonical-json.d.ts +34 -0
- package/dist/engine/publication-receipt/canonical-json.d.ts.map +1 -0
- package/dist/engine/publication-receipt/canonical-json.js +100 -0
- package/dist/engine/publication-receipt/canonical-json.js.map +1 -0
- package/dist/engine/publication-receipt/receipt.d.ts +316 -0
- package/dist/engine/publication-receipt/receipt.d.ts.map +1 -0
- package/dist/engine/publication-receipt/receipt.js +131 -0
- package/dist/engine/publication-receipt/receipt.js.map +1 -0
- package/dist/engine/publication-receipt/signature.d.ts +36 -0
- package/dist/engine/publication-receipt/signature.d.ts.map +1 -0
- package/dist/engine/publication-receipt/signature.js +79 -0
- package/dist/engine/publication-receipt/signature.js.map +1 -0
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +2 -0
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +23 -1
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/source-sections.d.ts +20 -0
- package/dist/engine/source-sections.d.ts.map +1 -0
- package/dist/engine/source-sections.js +130 -0
- package/dist/engine/source-sections.js.map +1 -0
- package/dist/engine/validator/checks/source-section-diagnostics.d.ts +7 -0
- package/dist/engine/validator/checks/source-section-diagnostics.d.ts.map +1 -0
- package/dist/engine/validator/checks/source-section-diagnostics.js +120 -0
- package/dist/engine/validator/checks/source-section-diagnostics.js.map +1 -0
- package/dist/engine/validator/index.d.ts +10 -0
- package/dist/engine/validator/index.d.ts.map +1 -1
- package/dist/engine/validator/index.js +4 -0
- package/dist/engine/validator/index.js.map +1 -1
- package/dist/engine/validator/types.d.ts +1 -1
- package/dist/engine/validator/types.d.ts.map +1 -1
- package/dist/engine/validator/types.js +10 -0
- package/dist/engine/validator/types.js.map +1 -1
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +10 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/providers/types.d.ts +1 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +22 -2
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/fronts-publish.d.ts +10 -0
- package/dist/routes/fronts-publish.d.ts.map +1 -0
- package/dist/routes/fronts-publish.js +250 -0
- package/dist/routes/fronts-publish.js.map +1 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +4 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +13 -1
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +28 -28
- package/dist/schema/layout.d.ts +2 -2
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +36 -0
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/portable-text.d.ts +2 -2
- package/dist/schema/tables.d.ts +1 -1
- package/dist/schema/tables.d.ts.map +1 -1
- package/dist/schema/tables.js +2 -0
- package/dist/schema/tables.js.map +1 -1
- package/dist/schema/types.d.ts +59 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/dist/surveys/access.d.ts +2 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +7 -0
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/errors.d.ts +8 -0
- package/dist/surveys/errors.d.ts.map +1 -1
- package/dist/surveys/errors.js +12 -0
- package/dist/surveys/errors.js.map +1 -1
- package/dist/surveys/schema.d.ts +129 -54
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +45 -0
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/fronts/publish-callback.d.ts +3 -0
- package/dist/ui/api/fronts/publish-callback.d.ts.map +1 -0
- package/dist/ui/api/fronts/publish-callback.js +8 -0
- package/dist/ui/api/fronts/publish-callback.js.map +1 -0
- package/dist/ui/api/fronts/schedule.d.ts +3 -0
- package/dist/ui/api/fronts/schedule.d.ts.map +1 -0
- package/dist/ui/api/fronts/schedule.js +8 -0
- package/dist/ui/api/fronts/schedule.js.map +1 -0
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +52 -1
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/body-warnings.d.ts +27 -0
- package/dist/ui/editor/body-warnings.d.ts.map +1 -0
- package/dist/ui/editor/body-warnings.js +58 -0
- package/dist/ui/editor/body-warnings.js.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +7 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +141 -32
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/migrations/0026_fronts_publish.sql +31 -0
- package/migrations/0027_fronts_publish_intent.sql +44 -0
- package/package.json +1 -1
- package/src/engine/foundry-dispatch.ts +22 -6
- package/src/engine/fronts-publish-intent.ts +676 -0
- package/src/engine/fronts-publish.ts +417 -0
- package/src/engine/index.ts +60 -0
- package/src/engine/publication-continuity.ts +140 -0
- package/src/engine/publication-receipt/canonical-json.ts +123 -0
- package/src/engine/publication-receipt/receipt.ts +160 -0
- package/src/engine/publication-receipt/signature.ts +112 -0
- package/src/engine/publication.ts +2 -0
- package/src/engine/publisher.ts +23 -1
- package/src/engine/source-sections.ts +153 -0
- package/src/engine/validator/checks/source-section-diagnostics.ts +187 -0
- package/src/engine/validator/index.ts +12 -0
- package/src/engine/validator/types.ts +19 -0
- package/src/integration/index.ts +10 -0
- package/src/providers/types.ts +1 -1
- package/src/routes/content.ts +23 -2
- package/src/routes/fronts-publish.ts +292 -0
- package/src/routes/index.ts +8 -0
- package/src/routes/surveys.ts +12 -1
- package/src/schema/migrations.ts +36 -0
- package/src/schema/tables.ts +2 -0
- package/src/schema/types.ts +61 -0
- package/src/surveys/access.ts +9 -0
- package/src/surveys/errors.ts +12 -0
- package/src/surveys/schema.ts +47 -0
- package/src/ui/api/fronts/publish-callback.ts +18 -0
- package/src/ui/api/fronts/schedule.ts +18 -0
- package/src/ui/editor/ContentForm.tsx +93 -0
- package/src/ui/editor/body-warnings.ts +78 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +369 -121
package/src/routes/surveys.ts
CHANGED
|
@@ -28,9 +28,11 @@ export function createSurveyRoutes(config: SurveyRouteConfig): SurveyRouteHandle
|
|
|
28
28
|
if (denied) return withNoStoreAuthFailure(denied)
|
|
29
29
|
const surveyId = parseSurveyId(ctx)
|
|
30
30
|
if (!surveyId) return surveyErrorResponse('survey_invalid_request')
|
|
31
|
+
const offset = parseOffset(ctx)
|
|
32
|
+
if (offset === null) return surveyErrorResponse('survey_invalid_request')
|
|
31
33
|
try {
|
|
32
34
|
const parsed = SurveyResultsSchema.safeParse(
|
|
33
|
-
await providers.surveys?.getResults({ surveyId }),
|
|
35
|
+
await providers.surveys?.getResults({ surveyId, offset }),
|
|
34
36
|
)
|
|
35
37
|
if (!parsed.success || parsed.data.survey.id !== surveyId) {
|
|
36
38
|
return surveyErrorResponse('survey_provider_malformed')
|
|
@@ -70,6 +72,13 @@ async function requireSurveyAccess(authz: Authz, ctx: RouteContext): Promise<Res
|
|
|
70
72
|
return authz.requireRole('view_survey_responses' satisfies Capability, ctx)
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
function parseOffset(ctx: RouteContext): number | null {
|
|
76
|
+
const raw = new URL(ctx.request.url).searchParams.get('offset')
|
|
77
|
+
if (raw === null) return 0
|
|
78
|
+
const value = Number(raw)
|
|
79
|
+
return Number.isInteger(value) && value >= 0 ? value : null
|
|
80
|
+
}
|
|
81
|
+
|
|
73
82
|
function parseSurveyId(ctx: RouteContext): string | null {
|
|
74
83
|
const value = ctx.params.surveyId ?? ctx.params.id
|
|
75
84
|
const parsed = SurveyIdSchema.safeParse(value)
|
|
@@ -103,6 +112,8 @@ const ProviderFailureAllowlist = new Map<string, SurveyErrorCode>([
|
|
|
103
112
|
['404:survey_not_found', 'survey_not_found'],
|
|
104
113
|
['500:survey_internal_failure', 'survey_internal_failure'],
|
|
105
114
|
['502:survey_upstream_failure', 'survey_upstream_failure'],
|
|
115
|
+
['502:survey_upstream_auth_failed', 'survey_upstream_auth_failed'],
|
|
116
|
+
['502:survey_invalid_response', 'survey_invalid_response'],
|
|
106
117
|
['503:survey_provider_unavailable', 'survey_provider_unavailable'],
|
|
107
118
|
['503:survey_upstream_unavailable', 'survey_upstream_unavailable'],
|
|
108
119
|
])
|
package/src/schema/migrations.ts
CHANGED
|
@@ -841,6 +841,42 @@ ALTER TABLE article_content
|
|
|
841
841
|
ADD COLUMN layout_json TEXT;
|
|
842
842
|
`,
|
|
843
843
|
},
|
|
844
|
+
{
|
|
845
|
+
id: '0026_fronts_publish',
|
|
846
|
+
sql: `
|
|
847
|
+
ALTER TABLE content_items
|
|
848
|
+
ADD COLUMN fronts_publish_status TEXT
|
|
849
|
+
CHECK (fronts_publish_status IS NULL OR fronts_publish_status IN ('queued','publishing','live','late','failed'));
|
|
850
|
+
CREATE TABLE fronts_publish_events (
|
|
851
|
+
event_id TEXT PRIMARY KEY,
|
|
852
|
+
intent_id TEXT,
|
|
853
|
+
content_id TEXT NOT NULL,
|
|
854
|
+
version INTEGER,
|
|
855
|
+
correlation_id TEXT,
|
|
856
|
+
status TEXT NOT NULL CHECK (status IN ('queued','publishing','live','late','failed')),
|
|
857
|
+
occurred_at INTEGER,
|
|
858
|
+
receipt_json TEXT,
|
|
859
|
+
error TEXT,
|
|
860
|
+
published INTEGER NOT NULL DEFAULT 0,
|
|
861
|
+
attempt_id TEXT,
|
|
862
|
+
received_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
863
|
+
FOREIGN KEY (content_id) REFERENCES content_items(id) ON DELETE CASCADE
|
|
864
|
+
);
|
|
865
|
+
CREATE INDEX idx_fronts_publish_events_content ON fronts_publish_events(content_id, received_at DESC);
|
|
866
|
+
CREATE INDEX idx_fronts_publish_events_intent ON fronts_publish_events(intent_id);
|
|
867
|
+
`,
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
id: '0027_fronts_publish_intent',
|
|
871
|
+
sql: `
|
|
872
|
+
ALTER TABLE content_items
|
|
873
|
+
ADD COLUMN canonical_version INTEGER NOT NULL DEFAULT 1;
|
|
874
|
+
ALTER TABLE content_items
|
|
875
|
+
ADD COLUMN fronts_channel_targets TEXT;
|
|
876
|
+
ALTER TABLE content_items
|
|
877
|
+
ADD COLUMN fronts_publish_trigger_token TEXT;
|
|
878
|
+
`,
|
|
879
|
+
},
|
|
844
880
|
]
|
|
845
881
|
|
|
846
882
|
/**
|
package/src/schema/tables.ts
CHANGED
|
@@ -43,6 +43,8 @@ export const CMS_TABLES = [
|
|
|
43
43
|
'content_insights',
|
|
44
44
|
'content_insight_runs',
|
|
45
45
|
'content_insight_dismissals',
|
|
46
|
+
// Fronts publish-callback receiver log (migration 0026)
|
|
47
|
+
'fronts_publish_events',
|
|
46
48
|
] as const
|
|
47
49
|
|
|
48
50
|
export type CmsTableName = (typeof CMS_TABLES)[number]
|
package/src/schema/types.ts
CHANGED
|
@@ -20,6 +20,18 @@ export type ContentStatus = 'draft' | 'scheduled' | 'review' | 'published' | 'ar
|
|
|
20
20
|
/** content_items.visibility CHECK domain. */
|
|
21
21
|
export type ContentVisibility = 'free' | 'premium'
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* content_items.fronts_publish_status CHECK domain (migration 0026).
|
|
25
|
+
*
|
|
26
|
+
* The lifecycle of an item handed to the out-of-process Fronts publish worker
|
|
27
|
+
* (foundryd): it acknowledges the intent (`queued`), begins pushing to the
|
|
28
|
+
* external surfaces (`publishing`), confirms every surface is live (`live`),
|
|
29
|
+
* misses its target window (`late`), or gives up (`failed`). Only `live` (with a
|
|
30
|
+
* receipt) flips the item to `published`. NULL means the item was never routed
|
|
31
|
+
* through the additive Fronts publish path (the untouched D1 cron path owns it).
|
|
32
|
+
*/
|
|
33
|
+
export type FrontsPublishStatus = 'queued' | 'publishing' | 'live' | 'late' | 'failed'
|
|
34
|
+
|
|
23
35
|
/** video_content.processing_state CHECK domain (bead 08 state machine). */
|
|
24
36
|
export type VideoProcessingState =
|
|
25
37
|
| 'pending'
|
|
@@ -76,6 +88,55 @@ export interface ContentItemRow {
|
|
|
76
88
|
metadata_json: string
|
|
77
89
|
/** Optional namespaced source key used for idempotent archive imports (migration 0020). */
|
|
78
90
|
source_id: string | null
|
|
91
|
+
/**
|
|
92
|
+
* Lifecycle marker for the additive out-of-process Fronts publish path
|
|
93
|
+
* (migration 0026). NULL for items the legacy D1 cron path owns.
|
|
94
|
+
*/
|
|
95
|
+
fronts_publish_status: FrontsPublishStatus | null
|
|
96
|
+
/**
|
|
97
|
+
* Monotonic integer revision of this item's canonical content (migration
|
|
98
|
+
* 0027). 1 on create; advanced by `updateContentItem` only. This is the
|
|
99
|
+
* `version` component of `intent_id = masthead:<content_id>:<version>`, so it
|
|
100
|
+
* is deliberately NOT advanced by `fronts_publish_status` marker writes or by
|
|
101
|
+
* a schedule poll.
|
|
102
|
+
*/
|
|
103
|
+
canonical_version: number
|
|
104
|
+
/**
|
|
105
|
+
* JSON array of Fronts publish target slugs (migration 0027), or NULL when
|
|
106
|
+
* the item has not been armed for the out-of-process publish path. NULL is a
|
|
107
|
+
* loud hydration error, never an empty target list. Distinct from `channel`,
|
|
108
|
+
* which is the single editorial Fronts channel slug.
|
|
109
|
+
*/
|
|
110
|
+
fronts_channel_targets: string | null
|
|
111
|
+
/**
|
|
112
|
+
* Raw CMS-owned per-item publish trigger token (migration 0027), secret at
|
|
113
|
+
* rest like `video_content.processing_trigger_token`. Never leaves the
|
|
114
|
+
* package: the publish-intent contract exposes only `sha256:<hex>` of it.
|
|
115
|
+
*/
|
|
116
|
+
fronts_publish_trigger_token: string | null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Durable receiver log for foundryd's outbound publish callbacks (migration
|
|
121
|
+
* 0026). One row per `event_id` — the idempotency key. `receipt_json` stores the
|
|
122
|
+
* platform receipt (published_urls / platform_post_ids / sha256); `attempt_id`
|
|
123
|
+
* links to the `content_publication_attempts` 2PC ledger row when a confirmed
|
|
124
|
+
* `live` callback drove the publish.
|
|
125
|
+
*/
|
|
126
|
+
export interface FrontsPublishEventRow {
|
|
127
|
+
event_id: string
|
|
128
|
+
intent_id: string | null
|
|
129
|
+
content_id: string
|
|
130
|
+
version: number | null
|
|
131
|
+
correlation_id: string | null
|
|
132
|
+
status: FrontsPublishStatus
|
|
133
|
+
occurred_at: number | null
|
|
134
|
+
receipt_json: string | null
|
|
135
|
+
error: string | null
|
|
136
|
+
/** 1 when this callback flipped (or confirmed) the item to published. */
|
|
137
|
+
published: number
|
|
138
|
+
attempt_id: string | null
|
|
139
|
+
received_at: number
|
|
79
140
|
}
|
|
80
141
|
|
|
81
142
|
/** Standalone topic taxonomy row. Counts are derived from content_items.primary_topic. */
|
package/src/surveys/access.ts
CHANGED
|
@@ -9,3 +9,12 @@ export const SURVEY_RESPONSE_ROLES: ReadonlySet<CmsRole> = new Set([
|
|
|
9
9
|
export function canViewSurveyResponses(role: CmsRole | null | undefined): boolean {
|
|
10
10
|
return role != null && SURVEY_RESPONSE_ROLES.has(role)
|
|
11
11
|
}
|
|
12
|
+
|
|
13
|
+
// Aggregates are for anyone who can open the screen; individual answers and the
|
|
14
|
+
// respondent attached to them are not. Splitting these lets a team see what
|
|
15
|
+
// readers said in aggregate without exposing every member's words by name.
|
|
16
|
+
export const SURVEY_VERBATIM_ROLES: ReadonlySet<CmsRole> = new Set(['owner', 'senior_editor'])
|
|
17
|
+
|
|
18
|
+
export function canViewSurveyVerbatims(role: CmsRole | null | undefined): boolean {
|
|
19
|
+
return role != null && SURVEY_VERBATIM_ROLES.has(role)
|
|
20
|
+
}
|
package/src/surveys/errors.ts
CHANGED
|
@@ -29,6 +29,18 @@ export const SURVEY_ERROR_DEFINITIONS = {
|
|
|
29
29
|
status: 502,
|
|
30
30
|
error: 'Survey reporting could not be loaded.',
|
|
31
31
|
},
|
|
32
|
+
// Distinct causes, distinct codes. These previously collapsed into
|
|
33
|
+
// survey_upstream_failure, so the screen showed one message for an auth
|
|
34
|
+
// rejection, an upstream outage, and a payload it could not parse — which is
|
|
35
|
+
// how a broken fetch receiver in a consuming host stayed invisible for a month.
|
|
36
|
+
survey_upstream_auth_failed: {
|
|
37
|
+
status: 502,
|
|
38
|
+
error: 'Survey reporting rejected this site\u2019s credentials.',
|
|
39
|
+
},
|
|
40
|
+
survey_invalid_response: {
|
|
41
|
+
status: 502,
|
|
42
|
+
error: 'Survey reporting returned a response we could not read.',
|
|
43
|
+
},
|
|
32
44
|
survey_internal_failure: {
|
|
33
45
|
status: 500,
|
|
34
46
|
error: 'Survey reporting could not be loaded.',
|
package/src/surveys/schema.ts
CHANGED
|
@@ -29,6 +29,11 @@ const BoundedRecordIdSchema = z
|
|
|
29
29
|
.regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/)
|
|
30
30
|
const IsoTimestampSchema = z.string().datetime({ offset: true })
|
|
31
31
|
const CountSchema = z.number().int().nonnegative().max(MAX_COUNT)
|
|
32
|
+
const AnswerValueSchema = z.union([
|
|
33
|
+
z.string().max(MAX_FREE_TEXT_LENGTH),
|
|
34
|
+
z.number().int(),
|
|
35
|
+
z.array(z.string().max(MAX_OPTION_LENGTH)).max(MAX_CHOICE_OPTIONS),
|
|
36
|
+
])
|
|
32
37
|
const NonemptyBoundedStringSchema = z
|
|
33
38
|
.string()
|
|
34
39
|
.min(1)
|
|
@@ -208,6 +213,35 @@ export const SurveyResultsSchema = z
|
|
|
208
213
|
submittedAt: IsoTimestampSchema,
|
|
209
214
|
respondentSegment: SurveyRespondentSegmentSchema,
|
|
210
215
|
anonymous: z.boolean(),
|
|
216
|
+
// Opaque upstream contact handle. The host resolves its own member
|
|
217
|
+
// record from this; no email or name crosses the reporting boundary.
|
|
218
|
+
// Optional because the reporting upstream only emits these when the
|
|
219
|
+
// caller opts in. A consumer that always required them could not
|
|
220
|
+
// read a legacy payload, and this object is strict, so a consumer
|
|
221
|
+
// that never expected them discards the whole response.
|
|
222
|
+
contactId: BoundedRecordIdSchema.nullable().optional(),
|
|
223
|
+
// Host-enriched, optional. The consuming site joins contactId to its
|
|
224
|
+
// own member record and attaches a display label here; the reporting
|
|
225
|
+
// upstream never sees it. Absent means "not resolved", not "anonymous".
|
|
226
|
+
respondent: z
|
|
227
|
+
.object({
|
|
228
|
+
label: NonemptyBoundedStringSchema,
|
|
229
|
+
detail: z.string().max(MAX_OPTION_LENGTH).nullable().optional(),
|
|
230
|
+
})
|
|
231
|
+
.strict()
|
|
232
|
+
.nullable()
|
|
233
|
+
.optional(),
|
|
234
|
+
answers: z
|
|
235
|
+
.array(
|
|
236
|
+
z
|
|
237
|
+
.object({
|
|
238
|
+
questionId: SurveyQuestionIdSchema,
|
|
239
|
+
value: AnswerValueSchema,
|
|
240
|
+
})
|
|
241
|
+
.strict(),
|
|
242
|
+
)
|
|
243
|
+
.max(MAX_QUESTIONS)
|
|
244
|
+
.optional(),
|
|
211
245
|
})
|
|
212
246
|
.strict(),
|
|
213
247
|
)
|
|
@@ -256,6 +290,19 @@ export const SurveyResultsSchema = z
|
|
|
256
290
|
if (results.responses.length > results.survey.responseCount) {
|
|
257
291
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Response rows exceed survey total' })
|
|
258
292
|
}
|
|
293
|
+
for (const [index, response] of results.responses.entries()) {
|
|
294
|
+
const seen = new Set<string>()
|
|
295
|
+
for (const [answerIndex, answer] of (response.answers ?? []).entries()) {
|
|
296
|
+
if (seen.has(answer.questionId)) {
|
|
297
|
+
ctx.addIssue({
|
|
298
|
+
code: z.ZodIssueCode.custom,
|
|
299
|
+
path: ['responses', index, 'answers', answerIndex, 'questionId'],
|
|
300
|
+
message: 'Answers must reference distinct questions',
|
|
301
|
+
})
|
|
302
|
+
}
|
|
303
|
+
seen.add(answer.questionId)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
259
306
|
const responses = new Map<string, (typeof results.responses)[number]>()
|
|
260
307
|
for (const [index, response] of results.responses.entries()) {
|
|
261
308
|
if (responses.has(response.id)) {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// src/ui/api/fronts/publish-callback.ts
|
|
2
|
+
// POST /admin/api/fronts/publish-callback — receiver for foundryd's outbound
|
|
3
|
+
// publish-lifecycle callbacks. Idempotent by event_id / Idempotency-Key. On a
|
|
4
|
+
// confirmed `live` (with a receipt) it drives the item to published through the
|
|
5
|
+
// content_publication_attempts 2PC ledger. HMAC-SHA256 (X-Foundry-Signature vs
|
|
6
|
+
// env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher.
|
|
7
|
+
import type { APIContext } from 'astro'
|
|
8
|
+
import type { RouteContext } from '../../../routes/context.js'
|
|
9
|
+
import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.js'
|
|
10
|
+
import { buildSiteAuthz } from '../_authz.js'
|
|
11
|
+
|
|
12
|
+
function toCtx(c: APIContext): RouteContext {
|
|
13
|
+
const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
|
|
14
|
+
return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const POST = (c: APIContext) =>
|
|
18
|
+
createFrontsPublishRoutes({ authz: buildSiteAuthz(c) }).publishCallback(toCtx(c))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// src/ui/api/fronts/schedule.ts
|
|
2
|
+
// GET /admin/api/fronts/schedule?window=<from>..<to> — the machine-to-machine
|
|
3
|
+
// poll surface for the out-of-process Fronts publish worker (foundryd). Returns
|
|
4
|
+
// `scheduled` items due for publishing. HMAC-SHA256 (X-Foundry-Signature vs
|
|
5
|
+
// env.FOUNDRY_HMAC_SECRET) only — NOT behind requirePublisher. Sibling of the
|
|
6
|
+
// content foundry-callback / insights-ingest M2M routes.
|
|
7
|
+
import type { APIContext } from 'astro'
|
|
8
|
+
import type { RouteContext } from '../../../routes/context.js'
|
|
9
|
+
import { createFrontsPublishRoutes } from '../../../routes/fronts-publish.js'
|
|
10
|
+
import { buildSiteAuthz } from '../_authz.js'
|
|
11
|
+
|
|
12
|
+
function toCtx(c: APIContext): RouteContext {
|
|
13
|
+
const env = (c.locals as { cmsEnv?: Record<string, unknown> }).cmsEnv ?? {}
|
|
14
|
+
return { request: c.request, params: {}, db: env.SITE_DB as RouteContext['db'], env }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const GET = (c: APIContext) =>
|
|
18
|
+
createFrontsPublishRoutes({ authz: buildSiteAuthz(c) }).schedule(toCtx(c))
|
|
@@ -27,6 +27,7 @@ import { Icon } from '../icons.js'
|
|
|
27
27
|
import { uploadMediaAsset } from '../screens/media-upload.js'
|
|
28
28
|
import { AiAssistPanel } from './AiAssistPanel.js'
|
|
29
29
|
import { AUTOSAVE_DEBOUNCE_MS, shouldSnapshot } from './autosave.js'
|
|
30
|
+
import { type BodyWarning, describeBodyWarnings, sortBodyWarnings } from './body-warnings.js'
|
|
30
31
|
import {
|
|
31
32
|
buildContentCreatePayload,
|
|
32
33
|
buildContentUpdatePayload,
|
|
@@ -185,6 +186,12 @@ interface FormState {
|
|
|
185
186
|
lastSavedAt: number | null
|
|
186
187
|
/** Whether the AI assist panel is open. */
|
|
187
188
|
aiOpen: boolean
|
|
189
|
+
/**
|
|
190
|
+
* Warn-severity body lint from the last save (#329). Previously the 200
|
|
191
|
+
* payload was discarded, which is how a malformed Sources section reached a
|
|
192
|
+
* published page — the server reported it and nothing showed the editor.
|
|
193
|
+
*/
|
|
194
|
+
bodyWarnings: BodyWarning[]
|
|
188
195
|
}
|
|
189
196
|
|
|
190
197
|
const EMPTY_CONTENT_METADATA_SELECTS: ContentMetadataSelectField[] = []
|
|
@@ -229,6 +236,7 @@ function createEmptyFormState(docId: string | null, metadata: ContentMetadata =
|
|
|
229
236
|
savedId: docId,
|
|
230
237
|
saving: false,
|
|
231
238
|
saveError: null,
|
|
239
|
+
bodyWarnings: [],
|
|
232
240
|
lastSavedAt: null,
|
|
233
241
|
aiOpen: false,
|
|
234
242
|
}
|
|
@@ -441,6 +449,17 @@ export function ContentForm({
|
|
|
441
449
|
return
|
|
442
450
|
}
|
|
443
451
|
|
|
452
|
+
// Carry warn-severity body lint into the editor. The server has
|
|
453
|
+
// reported these since #453; dropping the 200 payload here is what
|
|
454
|
+
// left them invisible.
|
|
455
|
+
const saved = (await res.json().catch(() => ({}))) as {
|
|
456
|
+
bodyWarnings?: BodyWarning[]
|
|
457
|
+
}
|
|
458
|
+
setForm((prev) => ({
|
|
459
|
+
...prev,
|
|
460
|
+
bodyWarnings: Array.isArray(saved.bodyWarnings) ? saved.bodyWarnings : [],
|
|
461
|
+
}))
|
|
462
|
+
|
|
444
463
|
// Snapshot cadence check
|
|
445
464
|
const now = Date.now()
|
|
446
465
|
savesSinceSnapshot.current += 1
|
|
@@ -1104,6 +1123,33 @@ export function ContentForm({
|
|
|
1104
1123
|
)}
|
|
1105
1124
|
</div>
|
|
1106
1125
|
|
|
1126
|
+
{/* Body lint (#329). Shown beside the body editor so the correction is
|
|
1127
|
+
next to the text it applies to, rather than in a toast that is gone
|
|
1128
|
+
before the editor can act on it. */}
|
|
1129
|
+
{form.bodyWarnings.length > 0 && (
|
|
1130
|
+
<div style={bodyWarningPanel} role="status" aria-label="Body formatting warnings">
|
|
1131
|
+
<div style={bodyWarningTitle}>
|
|
1132
|
+
<Icon name="edit" size={12} style={{ color: 'var(--ink-faint)' }} />
|
|
1133
|
+
<span>
|
|
1134
|
+
{form.bodyWarnings.length}{' '}
|
|
1135
|
+
{form.bodyWarnings.length === 1 ? 'formatting issue' : 'formatting issues'} — saved,
|
|
1136
|
+
but these will block publishing
|
|
1137
|
+
</span>
|
|
1138
|
+
</div>
|
|
1139
|
+
<ul style={bodyWarningList}>
|
|
1140
|
+
{sortBodyWarnings(describeBodyWarnings(form.body, form.bodyWarnings)).map((warning) => (
|
|
1141
|
+
<li key={`${warning.code}:${warning.line ?? 'x'}`} style={bodyWarningItem}>
|
|
1142
|
+
<span style={bodyWarningLine}>
|
|
1143
|
+
{warning.line === null ? 'Sources' : `Line ${warning.line}`}
|
|
1144
|
+
</span>
|
|
1145
|
+
<span>{warning.message}</span>
|
|
1146
|
+
{warning.excerpt ? <code style={bodyWarningCode}>{warning.excerpt}</code> : null}
|
|
1147
|
+
</li>
|
|
1148
|
+
))}
|
|
1149
|
+
</ul>
|
|
1150
|
+
</div>
|
|
1151
|
+
)}
|
|
1152
|
+
|
|
1107
1153
|
{/* Footer: word count + read time (only for prose content) */}
|
|
1108
1154
|
{(contentType === 'article' || contentType === 'newsletter' || contentType === 'page') && (
|
|
1109
1155
|
<div style={footer}>
|
|
@@ -1680,6 +1726,53 @@ const dekInput: React.CSSProperties = {
|
|
|
1680
1726
|
resize: 'none',
|
|
1681
1727
|
}
|
|
1682
1728
|
|
|
1729
|
+
const bodyWarningPanel: React.CSSProperties = {
|
|
1730
|
+
marginTop: 16,
|
|
1731
|
+
padding: '12px 14px',
|
|
1732
|
+
borderRadius: 6,
|
|
1733
|
+
border: '1px solid var(--border-soft)',
|
|
1734
|
+
borderLeft: '3px solid var(--warn, #b8860b)',
|
|
1735
|
+
background: 'var(--surface-sunken, rgba(0,0,0,0.02))',
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
const bodyWarningTitle: React.CSSProperties = {
|
|
1739
|
+
display: 'flex',
|
|
1740
|
+
alignItems: 'center',
|
|
1741
|
+
gap: 8,
|
|
1742
|
+
fontFamily: 'var(--mono)',
|
|
1743
|
+
fontSize: 11.5,
|
|
1744
|
+
color: 'var(--ink-muted)',
|
|
1745
|
+
marginBottom: 8,
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
const bodyWarningList: React.CSSProperties = {
|
|
1749
|
+
display: 'grid',
|
|
1750
|
+
gap: 8,
|
|
1751
|
+
margin: 0,
|
|
1752
|
+
padding: 0,
|
|
1753
|
+
listStyle: 'none',
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
const bodyWarningItem: React.CSSProperties = {
|
|
1757
|
+
display: 'grid',
|
|
1758
|
+
gap: 3,
|
|
1759
|
+
fontSize: 13,
|
|
1760
|
+
lineHeight: 1.45,
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const bodyWarningLine: React.CSSProperties = {
|
|
1764
|
+
fontFamily: 'var(--mono)',
|
|
1765
|
+
fontSize: 11,
|
|
1766
|
+
color: 'var(--ink-faint)',
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
const bodyWarningCode: React.CSSProperties = {
|
|
1770
|
+
fontFamily: 'var(--mono)',
|
|
1771
|
+
fontSize: 11.5,
|
|
1772
|
+
color: 'var(--ink-muted)',
|
|
1773
|
+
overflowWrap: 'anywhere',
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1683
1776
|
const footer: React.CSSProperties = {
|
|
1684
1777
|
display: 'flex',
|
|
1685
1778
|
alignItems: 'center',
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Turn validator output into something an editor can act on (#329).
|
|
2
|
+
//
|
|
3
|
+
// The API returns `bodyWarnings` with character spans. An editor cannot use a
|
|
4
|
+
// character offset — the design asks for "the affected line and a plain-language
|
|
5
|
+
// correction" — so this maps each span onto a 1-based line number and the text
|
|
6
|
+
// of that line.
|
|
7
|
+
//
|
|
8
|
+
// Pure on purpose: the mapping is the part worth testing, and keeping it out of
|
|
9
|
+
// the component means it can be tested without rendering anything.
|
|
10
|
+
|
|
11
|
+
export interface BodyWarning {
|
|
12
|
+
code: string
|
|
13
|
+
message: string
|
|
14
|
+
span?: { start: number; end: number }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DescribedBodyWarning {
|
|
18
|
+
code: string
|
|
19
|
+
message: string
|
|
20
|
+
/** 1-based line number, or null when the warning carries no span. */
|
|
21
|
+
line: number | null
|
|
22
|
+
/** The offending line, trimmed. Null when there is no span to locate. */
|
|
23
|
+
excerpt: string | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Longest excerpt shown inline before truncation. */
|
|
27
|
+
const MAX_EXCERPT = 120
|
|
28
|
+
|
|
29
|
+
function lineAt(markdown: string, offset: number): { line: number; text: string } | null {
|
|
30
|
+
if (offset < 0 || offset > markdown.length) return null
|
|
31
|
+
const before = markdown.slice(0, offset)
|
|
32
|
+
const line = before.split('\n').length
|
|
33
|
+
const lineStart = before.lastIndexOf('\n') + 1
|
|
34
|
+
const nextBreak = markdown.indexOf('\n', lineStart)
|
|
35
|
+
const text = markdown.slice(lineStart, nextBreak === -1 ? markdown.length : nextBreak)
|
|
36
|
+
return { line, text }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function truncate(value: string): string {
|
|
40
|
+
return value.length > MAX_EXCERPT ? `${value.slice(0, MAX_EXCERPT - 1)}…` : value
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Attach a line number and excerpt to each warning.
|
|
45
|
+
*
|
|
46
|
+
* Warnings without a span keep `line: null` rather than being dropped or given
|
|
47
|
+
* a fabricated line 1 — a wrong line number sends an editor to the wrong place,
|
|
48
|
+
* which is worse than admitting the position is unknown.
|
|
49
|
+
*/
|
|
50
|
+
export function describeBodyWarnings(
|
|
51
|
+
markdown: string | null | undefined,
|
|
52
|
+
warnings: readonly BodyWarning[] | null | undefined,
|
|
53
|
+
): DescribedBodyWarning[] {
|
|
54
|
+
if (!warnings || warnings.length === 0) return []
|
|
55
|
+
const source = markdown ?? ''
|
|
56
|
+
|
|
57
|
+
return warnings.map((warning) => {
|
|
58
|
+
const located = warning.span ? lineAt(source, warning.span.start) : null
|
|
59
|
+
return {
|
|
60
|
+
code: warning.code,
|
|
61
|
+
message: warning.message,
|
|
62
|
+
line: located ? located.line : null,
|
|
63
|
+
excerpt: located ? truncate(located.text.trim()) : null,
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Stable ordering for display: by line, then by code. Unlocated ones last. */
|
|
69
|
+
export function sortBodyWarnings(
|
|
70
|
+
warnings: readonly DescribedBodyWarning[],
|
|
71
|
+
): DescribedBodyWarning[] {
|
|
72
|
+
return [...warnings].sort((a, b) => {
|
|
73
|
+
if (a.line === b.line) return a.code.localeCompare(b.code)
|
|
74
|
+
if (a.line === null) return 1
|
|
75
|
+
if (b.line === null) return -1
|
|
76
|
+
return a.line - b.line
|
|
77
|
+
})
|
|
78
|
+
}
|