@growth-labs/cms 0.8.19 → 0.8.23
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/cli/foundry-emit.d.ts +14 -0
- package/dist/cli/foundry-emit.d.ts.map +1 -0
- package/dist/cli/foundry-emit.js +69 -0
- package/dist/cli/foundry-emit.js.map +1 -0
- package/dist/engine/fronts-publish-intent.d.ts +1 -1
- package/dist/engine/fronts-publish-intent.d.ts.map +1 -1
- package/dist/engine/fronts-publish-intent.js +12 -1
- package/dist/engine/fronts-publish-intent.js.map +1 -1
- package/dist/engine/publication-receipt/receipt.d.ts +20 -20
- package/dist/engine/publisher.d.ts +16 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +55 -4
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/tags.d.ts.map +1 -1
- package/dist/engine/tags.js +18 -1
- package/dist/engine/tags.js.map +1 -1
- package/dist/engine/topics.d.ts.map +1 -1
- package/dist/engine/topics.js +4 -1
- package/dist/engine/topics.js.map +1 -1
- package/dist/engine/video-source.d.ts +21 -0
- package/dist/engine/video-source.d.ts.map +1 -1
- package/dist/engine/video-source.js +40 -0
- package/dist/engine/video-source.js.map +1 -1
- package/dist/foundry-emit.d.ts +146 -0
- package/dist/foundry-emit.d.ts.map +1 -0
- package/dist/foundry-emit.js +189 -0
- package/dist/foundry-emit.js.map +1 -0
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +9 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +24 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +22 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/lib/publish-timezone.d.ts +38 -0
- package/dist/lib/publish-timezone.d.ts.map +1 -0
- package/dist/lib/publish-timezone.js +135 -0
- package/dist/lib/publish-timezone.js.map +1 -0
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +6 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +56 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/ai.d.ts.map +1 -1
- package/dist/routes/ai.js +21 -13
- package/dist/routes/ai.js.map +1 -1
- package/dist/routes/analytics.d.ts +1 -0
- package/dist/routes/analytics.d.ts.map +1 -1
- package/dist/routes/analytics.js +20 -0
- package/dist/routes/analytics.js.map +1 -1
- package/dist/routes/content.d.ts +27 -0
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +60 -114
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/foundry-publish.d.ts +50 -0
- package/dist/routes/foundry-publish.d.ts.map +1 -1
- package/dist/routes/foundry-publish.js +540 -27
- package/dist/routes/foundry-publish.js.map +1 -1
- package/dist/routes/machine-context.d.ts +6 -0
- package/dist/routes/machine-context.d.ts.map +1 -0
- package/dist/routes/machine-context.js +20 -0
- package/dist/routes/machine-context.js.map +1 -0
- package/dist/routes/surveys.d.ts +14 -1
- package/dist/routes/surveys.d.ts.map +1 -1
- package/dist/routes/surveys.js +515 -10
- package/dist/routes/surveys.js.map +1 -1
- package/dist/schema/insights-ingest.d.ts +8 -8
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +15 -0
- package/dist/schema/migrations.js.map +1 -1
- 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/surveys/access.d.ts +1 -0
- package/dist/surveys/access.d.ts.map +1 -1
- package/dist/surveys/access.js +13 -4
- package/dist/surveys/access.js.map +1 -1
- package/dist/surveys/auto-label.d.ts +100 -0
- package/dist/surveys/auto-label.d.ts.map +1 -0
- package/dist/surveys/auto-label.js +325 -0
- package/dist/surveys/auto-label.js.map +1 -0
- package/dist/surveys/schema.d.ts +38 -12
- package/dist/surveys/schema.d.ts.map +1 -1
- package/dist/surveys/schema.js +18 -1
- package/dist/surveys/schema.js.map +1 -1
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +4 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/analytics.d.ts.map +1 -1
- package/dist/ui/api/analytics.js +5 -2
- package/dist/ui/api/analytics.js.map +1 -1
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts +3 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.d.ts.map +1 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js +14 -0
- package/dist/ui/api/foundry/articles/[external_id]/publish.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js +17 -0
- package/dist/ui/api/surveys/[surveyId]/answers/label.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -1
- package/dist/ui/api/surveys/[surveyId].js +28 -1
- package/dist/ui/api/surveys/[surveyId].js.map +1 -1
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +2 -0
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/hash-router.d.ts +1 -1
- package/dist/ui/hash-router.d.ts.map +1 -1
- package/dist/ui/hash-router.js +1 -0
- package/dist/ui/hash-router.js.map +1 -1
- package/dist/ui/inspector/PublishTab.d.ts +7 -1
- package/dist/ui/inspector/PublishTab.d.ts.map +1 -1
- package/dist/ui/inspector/PublishTab.js +24 -26
- package/dist/ui/inspector/PublishTab.js.map +1 -1
- package/dist/ui/nav.d.ts.map +1 -1
- package/dist/ui/nav.js +1 -0
- package/dist/ui/nav.js.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts +6 -1
- package/dist/ui/screens/AnalyticsScreen.d.ts.map +1 -1
- package/dist/ui/screens/AnalyticsScreen.js +13 -8
- package/dist/ui/screens/AnalyticsScreen.js.map +1 -1
- package/dist/ui/screens/PodcastListeningScreen.d.ts +6 -0
- package/dist/ui/screens/PodcastListeningScreen.d.ts.map +1 -0
- package/dist/ui/screens/PodcastListeningScreen.js +244 -0
- package/dist/ui/screens/PodcastListeningScreen.js.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +4 -2
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.js +71 -12
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -1
- package/dist/ui/screens/analytics-data.d.ts +14 -0
- package/dist/ui/screens/analytics-data.d.ts.map +1 -1
- package/dist/ui/screens/analytics-data.js +16 -0
- package/dist/ui/screens/analytics-data.js.map +1 -1
- package/dist/ui/screens/podcast-listening-data.d.ts +19 -0
- package/dist/ui/screens/podcast-listening-data.d.ts.map +1 -0
- package/dist/ui/screens/podcast-listening-data.js +98 -0
- package/dist/ui/screens/podcast-listening-data.js.map +1 -0
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +2 -0
- package/dist/ui/screens/registry.js.map +1 -1
- package/docs/foundry-publish.md +208 -0
- package/migrations/0033_survey_answer_labels.sql +15 -0
- package/package.json +9 -3
- package/src/cli/foundry-emit.ts +85 -0
- package/src/engine/fronts-publish-intent.ts +23 -1
- package/src/engine/publisher.ts +80 -4
- package/src/engine/tags.ts +21 -1
- package/src/engine/topics.ts +4 -1
- package/src/engine/video-source.ts +44 -0
- package/src/foundry-emit.ts +255 -0
- package/src/integration/index.ts +9 -0
- package/src/integration/options.ts +23 -0
- package/src/lib/publish-timezone.ts +154 -0
- package/src/providers/null.ts +6 -0
- package/src/providers/types.ts +52 -0
- package/src/routes/ai.ts +29 -17
- package/src/routes/analytics.ts +21 -0
- package/src/routes/content.ts +86 -131
- package/src/routes/foundry-publish.ts +746 -29
- package/src/routes/machine-context.ts +24 -0
- package/src/routes/surveys.ts +587 -12
- package/src/schema/migrations.ts +15 -0
- package/src/schema/tables.ts +2 -0
- package/src/surveys/access.ts +14 -4
- package/src/surveys/auto-label.ts +420 -0
- package/src/surveys/schema.ts +25 -1
- package/src/ui/api/_content-config.ts +6 -0
- package/src/ui/api/analytics.ts +5 -2
- package/src/ui/api/foundry/articles/[external_id]/publish.ts +18 -0
- package/src/ui/api/surveys/[surveyId]/answers/label.ts +23 -0
- package/src/ui/api/surveys/[surveyId].ts +37 -3
- package/src/ui/commands.ts +2 -0
- package/src/ui/hash-router.ts +1 -0
- package/src/ui/inspector/PublishTab.tsx +33 -25
- package/src/ui/nav.ts +1 -0
- package/src/ui/screens/AnalyticsScreen.tsx +34 -6
- package/src/ui/screens/PodcastListeningScreen.tsx +529 -0
- package/src/ui/screens/SurveyResponsesScreen.tsx +179 -13
- package/src/ui/screens/analytics-data.ts +18 -0
- package/src/ui/screens/podcast-listening-data.ts +114 -0
- package/src/ui/screens/registry.tsx +2 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publication-timezone helpers — ONE implementation, shared by the schedule
|
|
3
|
+
* route and the Publish tab.
|
|
4
|
+
*
|
|
5
|
+
* Why this module exists: the editor types a wall-clock time and expects the
|
|
6
|
+
* post to go out at that time in the publication's timezone. That only holds
|
|
7
|
+
* if the digits the UI DISPLAYS and the zone it SAVES agree. When they
|
|
8
|
+
* disagreed, every resave shifted `publish_at` by the zone offset — including
|
|
9
|
+
* an incidental resave that never touched the field (the 2026-09-08 P0). So
|
|
10
|
+
* display and entry must use the same converter as the server, not a second
|
|
11
|
+
* copy of the arithmetic.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Fronts default; the server falls back to this when no zone is configured. */
|
|
15
|
+
export const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
|
|
16
|
+
|
|
17
|
+
/** `YYYY-MM-DDTHH:MM` exactly — the shape the date and time inputs produce. */
|
|
18
|
+
const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
|
|
19
|
+
|
|
20
|
+
type LocalDateTimeParts = { year: number; month: number; day: number; hour: number; minute: number }
|
|
21
|
+
|
|
22
|
+
export function parseLocalDateTime(value: string): LocalDateTimeParts | null {
|
|
23
|
+
const match = LOCAL_DATE_TIME_RE.exec(value)
|
|
24
|
+
if (!match) return null
|
|
25
|
+
const [, y, m, d, h, min] = match
|
|
26
|
+
const parts: LocalDateTimeParts = {
|
|
27
|
+
year: Number.parseInt(y, 10),
|
|
28
|
+
month: Number.parseInt(m, 10),
|
|
29
|
+
day: Number.parseInt(d, 10),
|
|
30
|
+
hour: Number.parseInt(h, 10),
|
|
31
|
+
minute: Number.parseInt(min, 10),
|
|
32
|
+
}
|
|
33
|
+
if (
|
|
34
|
+
!Number.isInteger(parts.year) ||
|
|
35
|
+
parts.month < 1 ||
|
|
36
|
+
parts.month > 12 ||
|
|
37
|
+
parts.day < 1 ||
|
|
38
|
+
parts.day > 31 ||
|
|
39
|
+
parts.hour < 0 ||
|
|
40
|
+
parts.hour > 23 ||
|
|
41
|
+
parts.minute < 0 ||
|
|
42
|
+
parts.minute > 59
|
|
43
|
+
) {
|
|
44
|
+
return null
|
|
45
|
+
}
|
|
46
|
+
const dateCheck = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
|
|
47
|
+
if (
|
|
48
|
+
dateCheck.getUTCFullYear() !== parts.year ||
|
|
49
|
+
dateCheck.getUTCMonth() + 1 !== parts.month ||
|
|
50
|
+
dateCheck.getUTCDate() !== parts.day
|
|
51
|
+
) {
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
return parts
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getZonedParts(instantMs: number, timeZone: string): LocalDateTimeParts | null {
|
|
58
|
+
if (!isValidTimeZone(timeZone)) return null
|
|
59
|
+
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
60
|
+
timeZone,
|
|
61
|
+
hour12: false,
|
|
62
|
+
year: 'numeric',
|
|
63
|
+
month: '2-digit',
|
|
64
|
+
day: '2-digit',
|
|
65
|
+
hour: '2-digit',
|
|
66
|
+
minute: '2-digit',
|
|
67
|
+
second: '2-digit',
|
|
68
|
+
})
|
|
69
|
+
const values = { year: '', month: '', day: '', hour: '', minute: '', second: '' }
|
|
70
|
+
for (const part of formatter.formatToParts(new Date(instantMs))) {
|
|
71
|
+
if (part.type in values) values[part.type as keyof typeof values] = part.value
|
|
72
|
+
}
|
|
73
|
+
const year = Number.parseInt(values.year, 10)
|
|
74
|
+
const month = Number.parseInt(values.month, 10)
|
|
75
|
+
const day = Number.parseInt(values.day, 10)
|
|
76
|
+
const hour = Number.parseInt(values.hour, 10)
|
|
77
|
+
const minute = Number.parseInt(values.minute, 10)
|
|
78
|
+
if (![year, month, day, hour, minute].every(Number.isFinite)) return null
|
|
79
|
+
return { year, month, day, hour, minute }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isValidTimeZone(timeZone: string): boolean {
|
|
83
|
+
try {
|
|
84
|
+
new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date())
|
|
85
|
+
return true
|
|
86
|
+
} catch {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getTimeZoneOffsetMs(instantMs: number, timeZone: string): number | null {
|
|
92
|
+
const zoned = getZonedParts(instantMs, timeZone)
|
|
93
|
+
if (!zoned) return null
|
|
94
|
+
const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0)
|
|
95
|
+
return asUtc - instantMs
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function parseLocalDateTimeInZoneToUnixSeconds(
|
|
99
|
+
value: string,
|
|
100
|
+
timeZone: string,
|
|
101
|
+
): number | null {
|
|
102
|
+
const target = parseLocalDateTime(value)
|
|
103
|
+
if (!target) return null
|
|
104
|
+
const targetUtcMs = Date.UTC(
|
|
105
|
+
target.year,
|
|
106
|
+
target.month - 1,
|
|
107
|
+
target.day,
|
|
108
|
+
target.hour,
|
|
109
|
+
target.minute,
|
|
110
|
+
0,
|
|
111
|
+
)
|
|
112
|
+
let candidateMs = targetUtcMs
|
|
113
|
+
for (let i = 0; i < 5; i += 1) {
|
|
114
|
+
const offsetMs = getTimeZoneOffsetMs(candidateMs, timeZone)
|
|
115
|
+
if (offsetMs === null) return null
|
|
116
|
+
const next = targetUtcMs - offsetMs
|
|
117
|
+
if (Math.abs(next - candidateMs) < 1000) {
|
|
118
|
+
candidateMs = next
|
|
119
|
+
break
|
|
120
|
+
}
|
|
121
|
+
candidateMs = next
|
|
122
|
+
}
|
|
123
|
+
const resolved = getZonedParts(candidateMs, timeZone)
|
|
124
|
+
if (
|
|
125
|
+
!resolved ||
|
|
126
|
+
resolved.year !== target.year ||
|
|
127
|
+
resolved.month !== target.month ||
|
|
128
|
+
resolved.day !== target.day ||
|
|
129
|
+
resolved.hour !== target.hour ||
|
|
130
|
+
resolved.minute !== target.minute
|
|
131
|
+
) {
|
|
132
|
+
return null
|
|
133
|
+
}
|
|
134
|
+
return Math.floor(candidateMs / 1000)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Inverse of `parseLocalDateTimeInZoneToUnixSeconds`: render an instant as the
|
|
139
|
+
* wall-clock digits an editor in `timeZone` would read, split for the date and
|
|
140
|
+
* time inputs. Returns null when the zone is unusable, so callers can fall back
|
|
141
|
+
* rather than silently display a wrong hour.
|
|
142
|
+
*/
|
|
143
|
+
export function formatInstantInZone(
|
|
144
|
+
instantMs: number,
|
|
145
|
+
timeZone: string,
|
|
146
|
+
): { date: string; time: string } | null {
|
|
147
|
+
const parts = getZonedParts(instantMs, timeZone)
|
|
148
|
+
if (!parts) return null
|
|
149
|
+
const pad = (n: number, width = 2) => String(n).padStart(width, '0')
|
|
150
|
+
return {
|
|
151
|
+
date: `${pad(parts.year, 4)}-${pad(parts.month)}-${pad(parts.day)}`,
|
|
152
|
+
time: `${pad(parts.hour)}:${pad(parts.minute)}`,
|
|
153
|
+
}
|
|
154
|
+
}
|
package/src/providers/null.ts
CHANGED
|
@@ -69,6 +69,12 @@ export const nullAnalytics: AnalyticsProvider = {
|
|
|
69
69
|
},
|
|
70
70
|
})
|
|
71
71
|
},
|
|
72
|
+
// Podcast listening analytics is a host opt-in: null (not an empty
|
|
73
|
+
// envelope) so the route 404s and the panel reports "not available"
|
|
74
|
+
// instead of rendering silent zeros for a site that has none.
|
|
75
|
+
async getPodcastListening() {
|
|
76
|
+
return null
|
|
77
|
+
},
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
export const nullSubscriptions: SubscriptionsProvider = {
|
package/src/providers/types.ts
CHANGED
|
@@ -89,6 +89,51 @@ export interface ArticleAnalyticsDetail {
|
|
|
89
89
|
funnel?: ArticleFunnelStages
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
+
export interface PodcastListeningEpisode {
|
|
93
|
+
slug: string
|
|
94
|
+
title?: string
|
|
95
|
+
/** Real press-play attempts (user-initiated audio_play; autoplay excluded). */
|
|
96
|
+
plays: number
|
|
97
|
+
/** Distinct sessions that played this episode. */
|
|
98
|
+
listeners: number
|
|
99
|
+
reached10: number
|
|
100
|
+
reached25: number
|
|
101
|
+
reached50: number
|
|
102
|
+
reached75: number
|
|
103
|
+
reached90: number
|
|
104
|
+
completes: number
|
|
105
|
+
/** completes / plays; 0 when plays is 0. */
|
|
106
|
+
completionRate: number
|
|
107
|
+
/**
|
|
108
|
+
* Total seconds actually listened. Null means NOT MEASURED — no
|
|
109
|
+
* `audio_played` rows exist for this episode in the range — and the UI
|
|
110
|
+
* must render "—", never a silent zero.
|
|
111
|
+
*/
|
|
112
|
+
totalListeningSeconds: number | null
|
|
113
|
+
/** totalListeningSeconds / listeners; null when either input is unmeasured. */
|
|
114
|
+
avgListeningSecondsPerListener: number | null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface PodcastListening {
|
|
118
|
+
episodes: PodcastListeningEpisode[]
|
|
119
|
+
totals: {
|
|
120
|
+
/** Episodes with at least one real play in the range. */
|
|
121
|
+
episodes: number
|
|
122
|
+
plays: number
|
|
123
|
+
/**
|
|
124
|
+
* Distinct sessions across every episode, deduplicated — never the sum
|
|
125
|
+
* of per-episode listeners, which would double-count a session that
|
|
126
|
+
* listened to two episodes.
|
|
127
|
+
*/
|
|
128
|
+
listeners: number
|
|
129
|
+
completes: number
|
|
130
|
+
completionRate: number
|
|
131
|
+
totalListeningSeconds: number | null
|
|
132
|
+
}
|
|
133
|
+
/** Echo of the requested window, ISO 8601 instants. */
|
|
134
|
+
range: { from: string; to: string }
|
|
135
|
+
}
|
|
136
|
+
|
|
92
137
|
export interface AnalyticsProvider {
|
|
93
138
|
getKpis(range: DateRange): Promise<ProviderResult<Record<string, number | null>>>
|
|
94
139
|
getReadsTimeSeries(range: DateRange): Promise<ProviderResult<Array<{ t: number; reads: number }>>>
|
|
@@ -99,6 +144,13 @@ export interface AnalyticsProvider {
|
|
|
99
144
|
range: DateRange,
|
|
100
145
|
): Promise<ProviderResult<Array<{ source: string; sessions: number }>>>
|
|
101
146
|
getArticleDetail(slug: string, range: DateRange): Promise<ProviderResult<ArticleAnalyticsDetail>>
|
|
147
|
+
/**
|
|
148
|
+
* Per-episode podcast listening analytics. Null means this site has no
|
|
149
|
+
* podcast listening analytics at all (the null provider) — the CMS hides
|
|
150
|
+
* the panel. A site that implements it but fails to read its source
|
|
151
|
+
* throws, so the route surfaces a loud error instead of zeros.
|
|
152
|
+
*/
|
|
153
|
+
getPodcastListening(input: { from: string; to: string }): Promise<PodcastListening | null>
|
|
102
154
|
}
|
|
103
155
|
|
|
104
156
|
export interface SubscriptionMember {
|
package/src/routes/ai.ts
CHANGED
|
@@ -23,7 +23,12 @@ import {
|
|
|
23
23
|
} from '../engine/ai-prompts.js'
|
|
24
24
|
import { applyAiWrite, parseLockedFields } from '../engine/ai-writeback.js'
|
|
25
25
|
import { buildOgCardSpec } from '../engine/og-render.js'
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
type ContentItemRecord,
|
|
28
|
+
canonicalEditStatement,
|
|
29
|
+
getContentItem,
|
|
30
|
+
updateContentItem,
|
|
31
|
+
} from '../engine/publisher.js'
|
|
27
32
|
import type { LlmDispatchResult } from '../providers/types.js'
|
|
28
33
|
import { canPerformContentAction } from './authz-matrix.js'
|
|
29
34
|
import type { CmsRouteConfig } from './config.js'
|
|
@@ -207,6 +212,8 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
|
207
212
|
if (result.status === 'unavailable') {
|
|
208
213
|
return llmUnavailableResponse({ reason: result.reason }, { dek: null, written: false })
|
|
209
214
|
}
|
|
215
|
+
// An editor-triggered dek is a human-initiated canonical change.
|
|
216
|
+
if (result.written) await canonicalEditStatement(ctx.db, idResult.id).run()
|
|
210
217
|
return json({
|
|
211
218
|
dek: result.dek,
|
|
212
219
|
written: result.written,
|
|
@@ -322,16 +329,19 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
|
322
329
|
|
|
323
330
|
if (writeResult.written) {
|
|
324
331
|
const now = Math.floor(Date.now() / 1000)
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
332
|
+
// An editor-triggered write-back is a human-initiated canonical change.
|
|
333
|
+
await ctx.db.batch([
|
|
334
|
+
ctx.db
|
|
335
|
+
.prepare(
|
|
336
|
+
`UPDATE article_content
|
|
328
337
|
SET ai_takeaways = ?,
|
|
329
338
|
ai_takeaways_at = ?,
|
|
330
339
|
ai_takeaways_model = ?
|
|
331
340
|
WHERE content_id = ?`,
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
.
|
|
341
|
+
)
|
|
342
|
+
.bind(JSON.stringify(takeaways), now, 'llm-dispatch', id),
|
|
343
|
+
canonicalEditStatement(ctx.db, id),
|
|
344
|
+
])
|
|
335
345
|
}
|
|
336
346
|
|
|
337
347
|
return json({
|
|
@@ -392,10 +402,9 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
|
392
402
|
})
|
|
393
403
|
|
|
394
404
|
if (writeResult.written) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
.run()
|
|
405
|
+
// Through the editor's own save path, so it is a canonical edit. The
|
|
406
|
+
// existing category is passed through so the save changes nothing else.
|
|
407
|
+
await updateContentItem(ctx.db, id, { description, primaryCategory: item.channel })
|
|
399
408
|
}
|
|
400
409
|
|
|
401
410
|
return json({
|
|
@@ -478,12 +487,15 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
|
478
487
|
})
|
|
479
488
|
|
|
480
489
|
if (writeResult.written) {
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
490
|
+
// An editor-triggered write-back is a human-initiated canonical change.
|
|
491
|
+
await ctx.db.batch([
|
|
492
|
+
ctx.db
|
|
493
|
+
.prepare(
|
|
494
|
+
'UPDATE content_items SET ai_og_image_id = ?, updated_at = unixepoch() WHERE id = ?',
|
|
495
|
+
)
|
|
496
|
+
.bind(imageKey, id),
|
|
497
|
+
canonicalEditStatement(ctx.db, id),
|
|
498
|
+
])
|
|
487
499
|
}
|
|
488
500
|
|
|
489
501
|
return json({
|
package/src/routes/analytics.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface AnalyticsRouteHandlers {
|
|
|
21
21
|
topContent(ctx: RouteContext): Promise<Response>
|
|
22
22
|
trafficSources(ctx: RouteContext): Promise<Response>
|
|
23
23
|
articleDetail(ctx: RouteContext): Promise<Response>
|
|
24
|
+
podcastListening(ctx: RouteContext): Promise<Response>
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/** Parse ?range=<Nd> from the request URL, defaulting to 30d. */
|
|
@@ -74,5 +75,25 @@ export function createAnalyticsRoutes(config: AnalyticsRouteConfig): AnalyticsRo
|
|
|
74
75
|
const slug = url.searchParams.get('slug') ?? ''
|
|
75
76
|
return json(await providers.analytics.getArticleDetail(slug, range))
|
|
76
77
|
},
|
|
78
|
+
|
|
79
|
+
// Podcast listening panel — same publisher gate and ?range=<Nd> window
|
|
80
|
+
// as the other analytics handlers, but NOT a ProviderResult envelope:
|
|
81
|
+
// the provider returns null when the site has no podcast listening
|
|
82
|
+
// analytics at all, which this route answers with a 404 the screen
|
|
83
|
+
// renders as an explicit "not available" state. A provider that is
|
|
84
|
+
// implemented but fails throws — the route 500s loudly, never zeros.
|
|
85
|
+
async podcastListening(ctx: RouteContext): Promise<Response> {
|
|
86
|
+
const denied = await config.authz.requirePublisher(ctx)
|
|
87
|
+
if (denied) return denied
|
|
88
|
+
const range = parseRange(ctx.request.url)
|
|
89
|
+
const listening = await providers.analytics.getPodcastListening({
|
|
90
|
+
from: new Date(range.from * 1000).toISOString(),
|
|
91
|
+
to: new Date(range.to * 1000).toISOString(),
|
|
92
|
+
})
|
|
93
|
+
if (!listening) {
|
|
94
|
+
return json({ error: 'podcast listening analytics is not available' }, 404)
|
|
95
|
+
}
|
|
96
|
+
return json(listening)
|
|
97
|
+
},
|
|
77
98
|
}
|
|
78
99
|
}
|
package/src/routes/content.ts
CHANGED
|
@@ -66,6 +66,12 @@ import { invalidateMediaCaptureStatements, softDeleteContent } from '../engine/s
|
|
|
66
66
|
import { normalizeSourceSections } from '../engine/source-sections.js'
|
|
67
67
|
import { TagInputError } from '../engine/taxonomy.js'
|
|
68
68
|
import type { ValidationError } from '../engine/validator/index.js'
|
|
69
|
+
import { VideoSourceNotIngestableError } from '../engine/video-source.js'
|
|
70
|
+
import {
|
|
71
|
+
isValidTimeZone,
|
|
72
|
+
PUBLISH_TIMEZONE_FALLBACK,
|
|
73
|
+
parseLocalDateTimeInZoneToUnixSeconds,
|
|
74
|
+
} from '../lib/publish-timezone.js'
|
|
69
75
|
import type { MediaSourceResolution } from '../providers/types.js'
|
|
70
76
|
import { parseLayoutEnvelope } from '../schema/layout.js'
|
|
71
77
|
import { parsePortableTextEnvelope } from '../schema/portable-text.js'
|
|
@@ -74,6 +80,8 @@ import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
|
|
|
74
80
|
import type { CmsRouteConfig } from './config.js'
|
|
75
81
|
import { resolveConfig } from './config.js'
|
|
76
82
|
import { json, type RouteContext, requireId } from './context.js'
|
|
83
|
+
import type { FoundryPublishOptions } from './foundry-publish.js'
|
|
84
|
+
import { isFoundryMachineContext } from './machine-context.js'
|
|
77
85
|
import type { DispatchWebhookHook } from './webhooks.js'
|
|
78
86
|
|
|
79
87
|
export type { DispatchWebhookHook }
|
|
@@ -141,6 +149,11 @@ function mediaSourceProviderErrorResponse(error: unknown): Response | null {
|
|
|
141
149
|
return json({ error: failure.message, reason: failure.code }, failure.status)
|
|
142
150
|
}
|
|
143
151
|
|
|
152
|
+
function videoSourceNotIngestableResponse(error: unknown): Response | null {
|
|
153
|
+
if (!(error instanceof VideoSourceNotIngestableError)) return null
|
|
154
|
+
return json({ error: error.message, reason: 'video_source_not_ingestable' }, 400)
|
|
155
|
+
}
|
|
156
|
+
|
|
144
157
|
function verifiedMediaDurationConflictResponse(error: unknown): Response | null {
|
|
145
158
|
if (!(error instanceof VerifiedMediaDurationConflictError)) return null
|
|
146
159
|
return json(
|
|
@@ -226,6 +239,14 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
226
239
|
videoSourceProductionEnabled?: boolean
|
|
227
240
|
/** Internal machine-route constraint: atomically update only in this status. */
|
|
228
241
|
updateStatusConstraint?: ContentStatus
|
|
242
|
+
/**
|
|
243
|
+
* Internal (Foundry machine route, M3-18): replaces ONLY the publish action's
|
|
244
|
+
* commit step (publishContent + createRevision) with the caller's — publishOne
|
|
245
|
+
* with the publication's live surfaces. Every other step of the native publish
|
|
246
|
+
* action (readiness, body guard, dek, tag check, activity log, takeaways, FAQ,
|
|
247
|
+
* the content.published webhook) runs unchanged, through the same handler.
|
|
248
|
+
*/
|
|
249
|
+
publishCommit?: (ctx: RouteContext, input: { contentId: string; now: number }) => Promise<void>
|
|
229
250
|
/**
|
|
230
251
|
* Optional webhook dispatcher hook (P7 Task 9). Fired at each content
|
|
231
252
|
* lifecycle transition (publish, unpublish, schedule, archive, create, etc.).
|
|
@@ -240,11 +261,23 @@ export interface ContentRouteConfig extends CmsRouteConfig {
|
|
|
240
261
|
* when absent so callers without a workspace concept still produce a scoped row.
|
|
241
262
|
*/
|
|
242
263
|
activeWorkspaceId?: string
|
|
264
|
+
/**
|
|
265
|
+
* Per-publication opt-in for the Foundry machine route's publish transition
|
|
266
|
+
* (M3-18). Omitted, or `autoPublish` anything but `true`, keeps that route
|
|
267
|
+
* draft-only and byte-identical to the v1 route. Read only by routes/foundry-publish.ts.
|
|
268
|
+
*/
|
|
269
|
+
foundryPublish?: FoundryPublishOptions
|
|
270
|
+
/**
|
|
271
|
+
* This publication's canonical host for the Foundry route family, declared
|
|
272
|
+
* statically (the integration's `foundryCanonicalHost`, so it reaches every
|
|
273
|
+
* request whatever the host's per-request hooks set). Once a host is declared
|
|
274
|
+
* here or in `foundryPublish.canonicalHost`, the Foundry route verifies only
|
|
275
|
+
* the host-bound `foundry-cms-v2` string and never the legacy dot form.
|
|
276
|
+
* Read only by routes/foundry-publish.ts.
|
|
277
|
+
*/
|
|
278
|
+
foundryCanonicalHost?: string
|
|
243
279
|
}
|
|
244
280
|
|
|
245
|
-
const PUBLISH_TIMEZONE_FALLBACK = 'Europe/Paris'
|
|
246
|
-
const LOCAL_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/
|
|
247
|
-
|
|
248
281
|
type ContentType = 'article' | 'video' | 'podcast' | 'newsletter' | 'page'
|
|
249
282
|
|
|
250
283
|
/**
|
|
@@ -420,6 +453,8 @@ const NEXTCLOUD_SHARE_HOSTS = ['drive.fulcrum-portal.com']
|
|
|
420
453
|
const NEXTCLOUD_SHARE_PATH_RE = /^\/s\/([A-Za-z0-9_-]+)(?:\/download)?\/?$/
|
|
421
454
|
const NEXTCLOUD_DIRECT_PATH_RE = /^\/public\.php\/dav\/files\/[A-Za-z0-9_-]+$/
|
|
422
455
|
|
|
456
|
+
const NEXTCLOUD_SHARE_NOISE_PARAMS: ReadonlySet<string> = new Set(['dir', 'editing', 'openfile'])
|
|
457
|
+
|
|
423
458
|
export function normaliseNextcloudShareUrl(value: string): string {
|
|
424
459
|
let url: URL
|
|
425
460
|
try {
|
|
@@ -438,13 +473,19 @@ export function normaliseNextcloudShareUrl(value: string): string {
|
|
|
438
473
|
) {
|
|
439
474
|
return value
|
|
440
475
|
}
|
|
441
|
-
|
|
442
|
-
|
|
476
|
+
// Nextcloud's copy-link output adds page-navigation noise -- `?dir=/` from a
|
|
477
|
+
// folder listing, `?dir=/&editing=false&openfile=true` from the viewer --
|
|
478
|
+
// none of which changes the file a share serves. Any other parameter may
|
|
479
|
+
// select a different resource, so the URL is kept and the publisher's
|
|
480
|
+
// ingest guard refuses it (HomeFronts 2026-10-01T18:05Z).
|
|
481
|
+
const onlyNoiseQuery =
|
|
482
|
+
url.search === '' ||
|
|
483
|
+
[...url.searchParams.keys()].every((key) => NEXTCLOUD_SHARE_NOISE_PARAMS.has(key))
|
|
443
484
|
if (NEXTCLOUD_DIRECT_PATH_RE.test(url.pathname)) {
|
|
444
|
-
return url.search === '' ? value :
|
|
485
|
+
return url.search === '' ? value : onlyNoiseQuery ? `https://${host}${url.pathname}` : value
|
|
445
486
|
}
|
|
446
487
|
const share = NEXTCLOUD_SHARE_PATH_RE.exec(url.pathname)
|
|
447
|
-
if (!share || !
|
|
488
|
+
if (!share || !onlyNoiseQuery) return value
|
|
448
489
|
return `https://${host}/public.php/dav/files/${share[1]}`
|
|
449
490
|
}
|
|
450
491
|
|
|
@@ -677,119 +718,6 @@ const ContributorsReplaceSchema = z.object({
|
|
|
677
718
|
|
|
678
719
|
// --- timezone-aware local-datetime parsing (ported verbatim) --------------
|
|
679
720
|
|
|
680
|
-
type LocalDateTimeParts = { year: number; month: number; day: number; hour: number; minute: number }
|
|
681
|
-
|
|
682
|
-
function parseLocalDateTime(value: string): LocalDateTimeParts | null {
|
|
683
|
-
const match = LOCAL_DATE_TIME_RE.exec(value)
|
|
684
|
-
if (!match) return null
|
|
685
|
-
const [, y, m, d, h, min] = match
|
|
686
|
-
const parts: LocalDateTimeParts = {
|
|
687
|
-
year: Number.parseInt(y, 10),
|
|
688
|
-
month: Number.parseInt(m, 10),
|
|
689
|
-
day: Number.parseInt(d, 10),
|
|
690
|
-
hour: Number.parseInt(h, 10),
|
|
691
|
-
minute: Number.parseInt(min, 10),
|
|
692
|
-
}
|
|
693
|
-
if (
|
|
694
|
-
!Number.isInteger(parts.year) ||
|
|
695
|
-
parts.month < 1 ||
|
|
696
|
-
parts.month > 12 ||
|
|
697
|
-
parts.day < 1 ||
|
|
698
|
-
parts.day > 31 ||
|
|
699
|
-
parts.hour < 0 ||
|
|
700
|
-
parts.hour > 23 ||
|
|
701
|
-
parts.minute < 0 ||
|
|
702
|
-
parts.minute > 59
|
|
703
|
-
) {
|
|
704
|
-
return null
|
|
705
|
-
}
|
|
706
|
-
const dateCheck = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
|
|
707
|
-
if (
|
|
708
|
-
dateCheck.getUTCFullYear() !== parts.year ||
|
|
709
|
-
dateCheck.getUTCMonth() + 1 !== parts.month ||
|
|
710
|
-
dateCheck.getUTCDate() !== parts.day
|
|
711
|
-
) {
|
|
712
|
-
return null
|
|
713
|
-
}
|
|
714
|
-
return parts
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
function getZonedParts(instantMs: number, timeZone: string): LocalDateTimeParts | null {
|
|
718
|
-
const formatter = new Intl.DateTimeFormat('en-US', {
|
|
719
|
-
timeZone,
|
|
720
|
-
hour12: false,
|
|
721
|
-
year: 'numeric',
|
|
722
|
-
month: '2-digit',
|
|
723
|
-
day: '2-digit',
|
|
724
|
-
hour: '2-digit',
|
|
725
|
-
minute: '2-digit',
|
|
726
|
-
second: '2-digit',
|
|
727
|
-
})
|
|
728
|
-
const values = { year: '', month: '', day: '', hour: '', minute: '', second: '' }
|
|
729
|
-
for (const part of formatter.formatToParts(new Date(instantMs))) {
|
|
730
|
-
if (part.type in values) values[part.type as keyof typeof values] = part.value
|
|
731
|
-
}
|
|
732
|
-
const year = Number.parseInt(values.year, 10)
|
|
733
|
-
const month = Number.parseInt(values.month, 10)
|
|
734
|
-
const day = Number.parseInt(values.day, 10)
|
|
735
|
-
const hour = Number.parseInt(values.hour, 10)
|
|
736
|
-
const minute = Number.parseInt(values.minute, 10)
|
|
737
|
-
if (![year, month, day, hour, minute].every(Number.isFinite)) return null
|
|
738
|
-
return { year, month, day, hour, minute }
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function isValidTimeZone(timeZone: string): boolean {
|
|
742
|
-
try {
|
|
743
|
-
new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date())
|
|
744
|
-
return true
|
|
745
|
-
} catch {
|
|
746
|
-
return false
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
function getTimeZoneOffsetMs(instantMs: number, timeZone: string): number | null {
|
|
751
|
-
const zoned = getZonedParts(instantMs, timeZone)
|
|
752
|
-
if (!zoned) return null
|
|
753
|
-
const asUtc = Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, 0)
|
|
754
|
-
return asUtc - instantMs
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
function parseLocalDateTimeInZoneToUnixSeconds(value: string, timeZone: string): number | null {
|
|
758
|
-
const target = parseLocalDateTime(value)
|
|
759
|
-
if (!target) return null
|
|
760
|
-
const targetUtcMs = Date.UTC(
|
|
761
|
-
target.year,
|
|
762
|
-
target.month - 1,
|
|
763
|
-
target.day,
|
|
764
|
-
target.hour,
|
|
765
|
-
target.minute,
|
|
766
|
-
0,
|
|
767
|
-
)
|
|
768
|
-
let candidateMs = targetUtcMs
|
|
769
|
-
for (let i = 0; i < 5; i += 1) {
|
|
770
|
-
const offsetMs = getTimeZoneOffsetMs(candidateMs, timeZone)
|
|
771
|
-
if (offsetMs === null) return null
|
|
772
|
-
const next = targetUtcMs - offsetMs
|
|
773
|
-
if (Math.abs(next - candidateMs) < 1000) {
|
|
774
|
-
candidateMs = next
|
|
775
|
-
break
|
|
776
|
-
}
|
|
777
|
-
candidateMs = next
|
|
778
|
-
}
|
|
779
|
-
const resolved = getZonedParts(candidateMs, timeZone)
|
|
780
|
-
if (
|
|
781
|
-
!resolved ||
|
|
782
|
-
resolved.year !== target.year ||
|
|
783
|
-
resolved.month !== target.month ||
|
|
784
|
-
resolved.day !== target.day ||
|
|
785
|
-
resolved.hour !== target.hour ||
|
|
786
|
-
resolved.minute !== target.minute
|
|
787
|
-
) {
|
|
788
|
-
return null
|
|
789
|
-
}
|
|
790
|
-
return Math.floor(candidateMs / 1000)
|
|
791
|
-
}
|
|
792
|
-
|
|
793
721
|
// --- read-side payload helpers (ported verbatim) --------------------------
|
|
794
722
|
|
|
795
723
|
async function getContentPayload(ctx: RouteContext, type: string, id: string) {
|
|
@@ -1293,6 +1221,15 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1293
1221
|
* — identical guard pattern to dispatchTakeaways / logActivity.
|
|
1294
1222
|
* When no hook is injected this is a no-op (no 501, no error).
|
|
1295
1223
|
*/
|
|
1224
|
+
/**
|
|
1225
|
+
* Only a context the HMAC-verified Foundry route marked is the machine
|
|
1226
|
+
* (routes/machine-context.ts); a subject or userId string never decides it,
|
|
1227
|
+
* so no person can act as the machine. Any other actor is a person.
|
|
1228
|
+
*/
|
|
1229
|
+
async function isFoundryActor(ctx: RouteContext): Promise<boolean> {
|
|
1230
|
+
return isFoundryMachineContext(ctx)
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1296
1233
|
async function fireWebhook(
|
|
1297
1234
|
ctx: RouteContext,
|
|
1298
1235
|
eventType: string,
|
|
@@ -1536,12 +1473,14 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1536
1473
|
if (op === 'topic') {
|
|
1537
1474
|
const { topic } = parsed.data
|
|
1538
1475
|
for (const id of ids) {
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1476
|
+
// Through the editor's own save path, so it is a canonical edit; the
|
|
1477
|
+
// existing category is passed through so the save changes nothing else.
|
|
1478
|
+
const existing = await getContentItem(ctx.db, id)
|
|
1479
|
+
if (!existing || (existing as { deleted_at?: number | null }).deleted_at != null) continue
|
|
1480
|
+
await updateContentItem(ctx.db, id, {
|
|
1481
|
+
primaryTopic: topic,
|
|
1482
|
+
primaryCategory: existing.channel,
|
|
1483
|
+
})
|
|
1545
1484
|
}
|
|
1546
1485
|
return json({ updated: ids })
|
|
1547
1486
|
}
|
|
@@ -1643,6 +1582,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1643
1582
|
created = await createContent(ctx.db, parsed.data)
|
|
1644
1583
|
} catch (error) {
|
|
1645
1584
|
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
1585
|
+
const notIngestable = videoSourceNotIngestableResponse(error)
|
|
1586
|
+
if (notIngestable) return notIngestable
|
|
1646
1587
|
const conflict = verifiedMediaDurationConflictResponse(error)
|
|
1647
1588
|
if (conflict) return conflict
|
|
1648
1589
|
throw error
|
|
@@ -1889,6 +1830,8 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1889
1830
|
})
|
|
1890
1831
|
} catch (error) {
|
|
1891
1832
|
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
1833
|
+
const notIngestable = videoSourceNotIngestableResponse(error)
|
|
1834
|
+
if (notIngestable) return notIngestable
|
|
1892
1835
|
const conflict = verifiedMediaDurationConflictResponse(error)
|
|
1893
1836
|
if (conflict) return conflict
|
|
1894
1837
|
throw error
|
|
@@ -2145,8 +2088,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
2145
2088
|
throw error
|
|
2146
2089
|
}
|
|
2147
2090
|
try {
|
|
2148
|
-
|
|
2149
|
-
|
|
2091
|
+
if (config.publishCommit) {
|
|
2092
|
+
await config.publishCommit(ctx, { contentId: id, now })
|
|
2093
|
+
} else {
|
|
2094
|
+
await publishContent(ctx.db, id, publishedAt, now)
|
|
2095
|
+
await createRevision(ctx.db, id, ctx.userId || null)
|
|
2096
|
+
}
|
|
2150
2097
|
} catch (error) {
|
|
2151
2098
|
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
2152
2099
|
throw error
|
|
@@ -2238,7 +2185,10 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
2238
2185
|
}
|
|
2239
2186
|
|
|
2240
2187
|
if (action === 'unpublish') {
|
|
2241
|
-
|
|
2188
|
+
// A person's unpublish sticks: it is a canonical edit, so a machine
|
|
2189
|
+
// publisher cannot put the item back without a new write. The
|
|
2190
|
+
// machine's own unpublish is not, so it can republish its item.
|
|
2191
|
+
await unpublishContent(ctx.db, id, { canonicalEdit: !(await isFoundryActor(ctx)) })
|
|
2242
2192
|
|
|
2243
2193
|
// Fire content.unpublished webhook.
|
|
2244
2194
|
await fireWebhook(ctx, 'content.unpublished', {
|
|
@@ -2274,9 +2224,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
2274
2224
|
if (action === 'unschedule') {
|
|
2275
2225
|
// publish_tz is NOT NULL (DEFAULT 'Europe/Paris'); reset it to the
|
|
2276
2226
|
// default rather than nulling it (a NULL violates the constraint).
|
|
2227
|
+
// A person pulling a scheduled item is a canonical edit (see unpublish).
|
|
2228
|
+
const pulledByPerson = !(await isFoundryActor(ctx))
|
|
2277
2229
|
await ctx.db
|
|
2278
2230
|
.prepare(
|
|
2279
|
-
|
|
2231
|
+
`UPDATE content_items SET status = 'draft', publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch()${pulledByPerson ? ', canonical_version = canonical_version + 1' : ''} WHERE id = ?`,
|
|
2280
2232
|
)
|
|
2281
2233
|
.bind(id)
|
|
2282
2234
|
.run()
|
|
@@ -2315,9 +2267,12 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
2315
2267
|
// publish_at so a de-scheduled item doesn't carry a phantom publish time.
|
|
2316
2268
|
// publish_tz is NOT NULL (DEFAULT 'Europe/Paris'), so it is reset to the
|
|
2317
2269
|
// default rather than nulled.
|
|
2270
|
+
// A person moving the item back to draft or review is a canonical
|
|
2271
|
+
// edit (see unpublish); the machine never calls set-status.
|
|
2272
|
+
const movedByPerson = !(await isFoundryActor(ctx))
|
|
2318
2273
|
await ctx.db
|
|
2319
2274
|
.prepare(
|
|
2320
|
-
|
|
2275
|
+
`UPDATE content_items SET status = ?, publish_at = NULL, publish_tz = 'Europe/Paris', updated_at = unixepoch()${movedByPerson ? ', canonical_version = canonical_version + 1' : ''} WHERE id = ?`,
|
|
2321
2276
|
)
|
|
2322
2277
|
.bind(status, id)
|
|
2323
2278
|
.run()
|