@growth-labs/cms 0.5.5 → 0.5.7
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/README.md +46 -0
- package/dist/engine/ai-dek-notifications.d.ts +16 -0
- package/dist/engine/ai-dek-notifications.d.ts.map +1 -0
- package/dist/engine/ai-dek-notifications.js +34 -0
- package/dist/engine/ai-dek-notifications.js.map +1 -0
- package/dist/engine/ai-dek.d.ts +35 -0
- package/dist/engine/ai-dek.d.ts.map +1 -0
- package/dist/engine/ai-dek.js +99 -0
- package/dist/engine/ai-dek.js.map +1 -0
- package/dist/engine/ai-prompts.d.ts +12 -0
- package/dist/engine/ai-prompts.d.ts.map +1 -1
- package/dist/engine/ai-prompts.js +17 -0
- package/dist/engine/ai-prompts.js.map +1 -1
- package/dist/engine/index.d.ts +3 -1
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +4 -1
- package/dist/engine/index.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/integration/index.d.ts.map +1 -1
- package/dist/integration/index.js +13 -0
- package/dist/integration/index.js.map +1 -1
- package/dist/integration/options.d.ts +19 -0
- package/dist/integration/options.d.ts.map +1 -1
- package/dist/integration/options.js +7 -0
- package/dist/integration/options.js.map +1 -1
- package/dist/providers/null.d.ts +2 -1
- package/dist/providers/null.d.ts.map +1 -1
- package/dist/providers/null.js +9 -0
- package/dist/providers/null.js.map +1 -1
- package/dist/providers/types.d.ts +10 -0
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/providers/types.js.map +1 -1
- package/dist/routes/ai.d.ts +2 -0
- package/dist/routes/ai.d.ts.map +1 -1
- package/dist/routes/ai.js +42 -0
- package/dist/routes/ai.js.map +1 -1
- package/dist/routes/authz-matrix.d.ts +2 -1
- package/dist/routes/authz-matrix.d.ts.map +1 -1
- package/dist/routes/authz-matrix.js +5 -0
- package/dist/routes/authz-matrix.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +43 -1
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/context.d.ts +1 -1
- package/dist/routes/context.d.ts.map +1 -1
- package/dist/routes/context.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +2 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/surveys.d.ts +12 -0
- package/dist/routes/surveys.d.ts.map +1 -0
- package/dist/routes/surveys.js +109 -0
- package/dist/routes/surveys.js.map +1 -0
- package/dist/schema/insights-ingest.d.ts +46 -46
- package/dist/surveys/access.d.ts +4 -0
- package/dist/surveys/access.d.ts.map +1 -0
- package/dist/surveys/access.js +9 -0
- package/dist/surveys/access.js.map +1 -0
- package/dist/surveys/errors.d.ts +43 -0
- package/dist/surveys/errors.d.ts.map +1 -0
- package/dist/surveys/errors.js +42 -0
- package/dist/surveys/errors.js.map +1 -0
- package/dist/surveys/schema.d.ts +939 -0
- package/dist/surveys/schema.d.ts.map +1 -0
- package/dist/surveys/schema.js +385 -0
- package/dist/surveys/schema.js.map +1 -0
- package/dist/ui/api/_content-config.d.ts.map +1 -1
- package/dist/ui/api/_content-config.js +1 -0
- package/dist/ui/api/_content-config.js.map +1 -1
- package/dist/ui/api/content/[id]/ai/dek.d.ts +3 -0
- package/dist/ui/api/content/[id]/ai/dek.d.ts.map +1 -0
- package/dist/ui/api/content/[id]/ai/dek.js +5 -0
- package/dist/ui/api/content/[id]/ai/dek.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.js +17 -0
- package/dist/ui/api/surveys/[surveyId]/responses.csv.js.map +1 -0
- package/dist/ui/api/surveys/[surveyId].d.ts +3 -0
- package/dist/ui/api/surveys/[surveyId].d.ts.map +1 -0
- package/dist/ui/api/surveys/[surveyId].js +17 -0
- package/dist/ui/api/surveys/[surveyId].js.map +1 -0
- package/dist/ui/client/mount.d.ts.map +1 -1
- package/dist/ui/client/mount.js +1 -0
- package/dist/ui/client/mount.js.map +1 -1
- package/dist/ui/commands.d.ts +5 -1
- package/dist/ui/commands.d.ts.map +1 -1
- package/dist/ui/commands.js +7 -0
- package/dist/ui/commands.js.map +1 -1
- package/dist/ui/components/CmsApp.d.ts +2 -0
- package/dist/ui/components/CmsApp.d.ts.map +1 -1
- package/dist/ui/components/CmsApp.js +14 -3
- package/dist/ui/components/CmsApp.js.map +1 -1
- package/dist/ui/components/CommandPalette.d.ts +2 -0
- package/dist/ui/components/CommandPalette.d.ts.map +1 -1
- package/dist/ui/components/CommandPalette.js +1 -1
- package/dist/ui/components/CommandPalette.js.map +1 -1
- package/dist/ui/components/Sidebar.d.ts +1 -0
- package/dist/ui/components/Sidebar.d.ts.map +1 -1
- package/dist/ui/components/Sidebar.js +1 -1
- package/dist/ui/components/Sidebar.js.map +1 -1
- package/dist/ui/editor/AiAssistPanel.d.ts +3 -1
- package/dist/ui/editor/AiAssistPanel.d.ts.map +1 -1
- package/dist/ui/editor/AiAssistPanel.js +28 -4
- package/dist/ui/editor/AiAssistPanel.js.map +1 -1
- package/dist/ui/editor/ContentForm.d.ts.map +1 -1
- package/dist/ui/editor/ContentForm.js +1 -1
- package/dist/ui/editor/ContentForm.js.map +1 -1
- package/dist/ui/editor/ai-assist.d.ts +2 -2
- package/dist/ui/editor/ai-assist.d.ts.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/nav.d.ts +5 -1
- package/dist/ui/nav.d.ts.map +1 -1
- package/dist/ui/nav.js +10 -2
- package/dist/ui/nav.js.map +1 -1
- package/dist/ui/screens/SurveyResponsesScreen.d.ts +28 -0
- package/dist/ui/screens/SurveyResponsesScreen.d.ts.map +1 -0
- package/dist/ui/screens/SurveyResponsesScreen.js +110 -0
- package/dist/ui/screens/SurveyResponsesScreen.js.map +1 -0
- package/dist/ui/screens/registry.d.ts +2 -1
- package/dist/ui/screens/registry.d.ts.map +1 -1
- package/dist/ui/screens/registry.js +5 -1
- package/dist/ui/screens/registry.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/ai-dek-notifications.ts +50 -0
- package/src/engine/ai-dek.ts +138 -0
- package/src/engine/ai-prompts.ts +25 -0
- package/src/engine/index.ts +14 -0
- package/src/index.ts +2 -2
- package/src/integration/index.ts +13 -0
- package/src/integration/options.ts +8 -0
- package/src/providers/null.ts +19 -0
- package/src/providers/types.ts +7 -0
- package/src/routes/ai.ts +44 -0
- package/src/routes/authz-matrix.ts +14 -1
- package/src/routes/content.ts +45 -1
- package/src/routes/context.ts +3 -0
- package/src/routes/index.ts +6 -0
- package/src/routes/surveys.ts +130 -0
- package/src/surveys/access.ts +11 -0
- package/src/surveys/errors.ts +51 -0
- package/src/surveys/schema.ts +435 -0
- package/src/ui/api/_content-config.ts +2 -0
- package/src/ui/api/content/[id]/ai/dek.ts +8 -0
- package/src/ui/api/surveys/[surveyId]/responses.csv.ts +23 -0
- package/src/ui/api/surveys/[surveyId].ts +23 -0
- package/src/ui/client/mount.tsx +1 -0
- package/src/ui/commands.ts +14 -1
- package/src/ui/components/CmsApp.tsx +25 -3
- package/src/ui/components/CommandPalette.tsx +2 -1
- package/src/ui/components/Sidebar.tsx +10 -2
- package/src/ui/editor/AiAssistPanel.tsx +55 -3
- package/src/ui/editor/ContentForm.tsx +3 -1
- package/src/ui/editor/ai-assist.ts +2 -2
- package/src/ui/hash-router.ts +1 -0
- package/src/ui/nav.ts +15 -2
- package/src/ui/screens/SurveyResponsesScreen.tsx +271 -0
- package/src/ui/screens/registry.tsx +6 -0
package/src/providers/types.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { SeoScoreInput, SeoScoreResult } from '../engine/seo.js'
|
|
2
|
+
import type { SurveyCsvResult, SurveyResults } from '../surveys/schema.js'
|
|
2
3
|
|
|
3
4
|
export type ProviderStatus = 'live' | 'partial' | 'empty'
|
|
4
5
|
export interface ProviderResult<T> {
|
|
@@ -402,6 +403,11 @@ export interface SettingsProvider {
|
|
|
402
403
|
getIntegrations(): Promise<ProviderResult<Record<string, CmsIntegrationStatus | null>>>
|
|
403
404
|
}
|
|
404
405
|
|
|
406
|
+
export interface SurveyProvider {
|
|
407
|
+
getResults(input: { surveyId: string }): Promise<SurveyResults>
|
|
408
|
+
getCsv(input: { surveyId: string }): Promise<SurveyCsvResult>
|
|
409
|
+
}
|
|
410
|
+
|
|
405
411
|
export interface CmsProviders {
|
|
406
412
|
analytics: AnalyticsProvider
|
|
407
413
|
subscriptions: SubscriptionsProvider
|
|
@@ -409,4 +415,5 @@ export interface CmsProviders {
|
|
|
409
415
|
seo: SeoProvider
|
|
410
416
|
search: SearchProvider
|
|
411
417
|
settings: SettingsProvider
|
|
418
|
+
surveys?: SurveyProvider
|
|
412
419
|
}
|
package/src/routes/ai.ts
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
// apply the ai_locked_fields gate, persist when allowed, and return the result.
|
|
15
15
|
// - OG-image generation lands in Task 5.
|
|
16
16
|
|
|
17
|
+
import { generateContentDek } from '../engine/ai-dek.js'
|
|
17
18
|
import {
|
|
18
19
|
headlinesPrompt,
|
|
19
20
|
metaDescriptionPrompt,
|
|
@@ -24,6 +25,7 @@ import { applyAiWrite, parseLockedFields } from '../engine/ai-writeback.js'
|
|
|
24
25
|
import { buildOgCardSpec } from '../engine/og-render.js'
|
|
25
26
|
import { type ContentItemRecord, getContentItem } from '../engine/publisher.js'
|
|
26
27
|
import type { LlmDispatchResult } from '../providers/types.js'
|
|
28
|
+
import { canPerformContentAction } from './authz-matrix.js'
|
|
27
29
|
import type { CmsRouteConfig } from './config.js'
|
|
28
30
|
import { resolveConfig } from './config.js'
|
|
29
31
|
import { type Authz, type Capability, json, type RouteContext, requireId } from './context.js'
|
|
@@ -45,6 +47,8 @@ async function aiGuard(authz: Authz, ctx: RouteContext): Promise<Response | null
|
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export interface AiRouteHandlers {
|
|
50
|
+
/** POST /content/:id/ai/dek — sync: generate + persist a display subheading. */
|
|
51
|
+
suggestDek(ctx: RouteContext): Promise<Response>
|
|
48
52
|
/** POST /content/:id/ai/proofread — SSE streaming proofread & tighten. */
|
|
49
53
|
proofreadAndTighten(ctx: RouteContext): Promise<Response>
|
|
50
54
|
/** POST /content/:id/ai/headlines — SSE streaming 5-headline suggestions. */
|
|
@@ -171,6 +175,45 @@ async function loadArticleBody(
|
|
|
171
175
|
export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
172
176
|
const resolved = resolveConfig(config)
|
|
173
177
|
|
|
178
|
+
async function suggestDek(ctx: RouteContext): Promise<Response> {
|
|
179
|
+
const idResult = requireId(ctx)
|
|
180
|
+
if ('error' in idResult) return idResult.error
|
|
181
|
+
const item = await getContentItem(ctx.db, idResult.id)
|
|
182
|
+
if (!item) return json({ error: 'Not found' }, 404)
|
|
183
|
+
|
|
184
|
+
if (resolved.authz.getUser) {
|
|
185
|
+
const user = await resolved.authz.getUser(ctx)
|
|
186
|
+
if (!user) return json({ error: 'Unauthorized' }, 401)
|
|
187
|
+
const allowed = canPerformContentAction(
|
|
188
|
+
user.role,
|
|
189
|
+
{ actorSubject: user.subject, contentAuthorId: item.author_id },
|
|
190
|
+
item.status === 'published' ? 'write_published' : 'write',
|
|
191
|
+
)
|
|
192
|
+
if (!allowed) return json({ error: 'Forbidden', role: user.role }, 403)
|
|
193
|
+
} else {
|
|
194
|
+
const authGuard = await aiGuard(resolved.authz, ctx)
|
|
195
|
+
if (authGuard) return authGuard
|
|
196
|
+
}
|
|
197
|
+
if (!resolved.hooks.llm) {
|
|
198
|
+
return json({ error: 'AI generation is not configured' }, 501)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const result = await generateContentDek({
|
|
202
|
+
db: ctx.db,
|
|
203
|
+
contentId: idResult.id,
|
|
204
|
+
llm: resolved.hooks.llm,
|
|
205
|
+
})
|
|
206
|
+
if (result.status === 'not_found') return json({ error: 'Not found' }, 404)
|
|
207
|
+
if (result.status === 'unavailable') {
|
|
208
|
+
return llmUnavailableResponse({ reason: result.reason }, { dek: null, written: false })
|
|
209
|
+
}
|
|
210
|
+
return json({
|
|
211
|
+
dek: result.dek,
|
|
212
|
+
written: result.written,
|
|
213
|
+
...(result.status === 'locked' ? { locked: true } : {}),
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
|
|
174
217
|
async function proofreadAndTighten(ctx: RouteContext): Promise<Response> {
|
|
175
218
|
// Guard: edit_own_draft capability (any member; ownership-aware write-back).
|
|
176
219
|
const authGuard = await aiGuard(resolved.authz, ctx)
|
|
@@ -452,6 +495,7 @@ export function createAiRoutes(config: CmsRouteConfig): AiRouteHandlers {
|
|
|
452
495
|
}
|
|
453
496
|
|
|
454
497
|
return {
|
|
498
|
+
suggestDek,
|
|
455
499
|
proofreadAndTighten,
|
|
456
500
|
suggestHeadlines,
|
|
457
501
|
generateKeyTakeaways,
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
|
|
21
21
|
import type { MembershipRecord } from '../engine/members.js'
|
|
22
22
|
import type { CmsRole } from '../schema/types.js'
|
|
23
|
+
import { SURVEY_RESPONSE_ROLES } from '../surveys/access.js'
|
|
23
24
|
import type { Capability, CmsUser, RouteContext } from './context.js'
|
|
24
25
|
import { json } from './context.js'
|
|
25
26
|
|
|
@@ -53,6 +54,7 @@ export const CAPABILITY_MATRIX: Record<Capability, ReadonlySet<CmsRole>> = {
|
|
|
53
54
|
publish_article: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
|
|
54
55
|
publish_video: new Set<CmsRole>(['owner', 'senior_editor', 'editor', 'producer']),
|
|
55
56
|
edit_own_draft: new Set<CmsRole>(['owner', 'senior_editor', 'editor', 'contributor', 'producer']),
|
|
57
|
+
edit_published: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
|
|
56
58
|
publish_others_draft: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
|
|
57
59
|
submit_for_review: new Set<CmsRole>([
|
|
58
60
|
'owner',
|
|
@@ -75,6 +77,7 @@ export const CAPABILITY_MATRIX: Record<Capability, ReadonlySet<CmsRole>> = {
|
|
|
75
77
|
// Complimentary access is an editorial admin action by explicit operator
|
|
76
78
|
// decision; it is not subscriber-PII export or owner-only settings.
|
|
77
79
|
manage_complimentary_access: new Set<CmsRole>(['owner', 'senior_editor', 'editor']),
|
|
80
|
+
view_survey_responses: SURVEY_RESPONSE_ROLES,
|
|
78
81
|
}
|
|
79
82
|
|
|
80
83
|
// ---------------------------------------------------------------------------
|
|
@@ -148,6 +151,7 @@ export function canActOnContent(
|
|
|
148
151
|
* - 'write' — create / update / save a draft, set-status, snapshot,
|
|
149
152
|
* contributors edit. Lowest bar: edit_own_draft (any member;
|
|
150
153
|
* contributor/producer gated to OWN content by canActOnContent).
|
|
154
|
+
* - 'write_published' — update published copy. Explicitly editorial-only.
|
|
151
155
|
* - 'publish' — publish/unpublish/archive/unschedule an article. Requires
|
|
152
156
|
* publish_others_draft (owner/senior_editor/editor); on OWN
|
|
153
157
|
* content publish_article suffices (still excludes contributor).
|
|
@@ -156,7 +160,14 @@ export function canActOnContent(
|
|
|
156
160
|
* - 'trash' — soft-delete / bulk-trash. Destructive → publish_others_draft.
|
|
157
161
|
* - 'media' — dispatch-to-foundry / regenerate AI artefacts. media_pipeline.
|
|
158
162
|
*/
|
|
159
|
-
export type ContentAction =
|
|
163
|
+
export type ContentAction =
|
|
164
|
+
| 'write'
|
|
165
|
+
| 'write_published'
|
|
166
|
+
| 'publish'
|
|
167
|
+
| 'publish_video'
|
|
168
|
+
| 'schedule'
|
|
169
|
+
| 'trash'
|
|
170
|
+
| 'media'
|
|
160
171
|
|
|
161
172
|
/**
|
|
162
173
|
* Resolve whether `role` may perform a content `action` on an item authored by
|
|
@@ -178,6 +189,8 @@ export function canPerformContentAction(
|
|
|
178
189
|
switch (action) {
|
|
179
190
|
case 'write':
|
|
180
191
|
return canActOnContent(role, ownership, 'edit_own_draft')
|
|
192
|
+
case 'write_published':
|
|
193
|
+
return canActOnContent(role, ownership, 'edit_published')
|
|
181
194
|
case 'media':
|
|
182
195
|
return canActOnContent(role, ownership, 'media_pipeline')
|
|
183
196
|
case 'publish_video':
|
package/src/routes/content.ts
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
import { z } from 'zod'
|
|
18
18
|
import { logActivity } from '../engine/activity-log.js'
|
|
19
|
+
import { generateContentDek } from '../engine/ai-dek.js'
|
|
20
|
+
import { createDekFailureNotifications } from '../engine/ai-dek-notifications.js'
|
|
19
21
|
import { listContributors, setContributors } from '../engine/contributors.js'
|
|
20
22
|
import type { D1Database } from '../engine/d1.js'
|
|
21
23
|
import {
|
|
@@ -1126,7 +1128,11 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1126
1128
|
if (!existing) return json({ error: 'Not found' }, 404)
|
|
1127
1129
|
|
|
1128
1130
|
// Edit = write; contributor/producer gated to their own content.
|
|
1129
|
-
const denied = await requireContentAction(
|
|
1131
|
+
const denied = await requireContentAction(
|
|
1132
|
+
ctx,
|
|
1133
|
+
existing.status === 'published' ? 'write_published' : 'write',
|
|
1134
|
+
existing.author_id,
|
|
1135
|
+
)
|
|
1130
1136
|
if (denied) return denied
|
|
1131
1137
|
|
|
1132
1138
|
const parsed = UpdateSchema.safeParse(await readJson(ctx))
|
|
@@ -1320,6 +1326,44 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1320
1326
|
422,
|
|
1321
1327
|
)
|
|
1322
1328
|
}
|
|
1329
|
+
|
|
1330
|
+
if (!existing.excerpt?.trim()) {
|
|
1331
|
+
let failureReason: string | null = null
|
|
1332
|
+
if (!resolved.hooks.llm) {
|
|
1333
|
+
failureReason = 'AI generation is not configured'
|
|
1334
|
+
} else {
|
|
1335
|
+
try {
|
|
1336
|
+
const generated = await generateContentDek({
|
|
1337
|
+
db: ctx.db,
|
|
1338
|
+
contentId: id,
|
|
1339
|
+
llm: resolved.hooks.llm,
|
|
1340
|
+
})
|
|
1341
|
+
if (generated.status === 'unavailable' || generated.status === 'not_found') {
|
|
1342
|
+
failureReason = generated.reason
|
|
1343
|
+
}
|
|
1344
|
+
} catch (error) {
|
|
1345
|
+
failureReason = error instanceof Error ? error.message : String(error)
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
if (failureReason) {
|
|
1349
|
+
console.error('[cms] dek generation failed; publishing without a subheading', {
|
|
1350
|
+
contentId: id,
|
|
1351
|
+
reason: failureReason,
|
|
1352
|
+
})
|
|
1353
|
+
try {
|
|
1354
|
+
await createDekFailureNotifications({
|
|
1355
|
+
db: ctx.db,
|
|
1356
|
+
workspaceId: activeWorkspaceId,
|
|
1357
|
+
contentId: id,
|
|
1358
|
+
title: existing.title,
|
|
1359
|
+
reason: failureReason,
|
|
1360
|
+
userId: ctx.userId,
|
|
1361
|
+
})
|
|
1362
|
+
} catch (notificationError) {
|
|
1363
|
+
console.error('[cms] failed to create dek generation notification', notificationError)
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1323
1367
|
await publishContent(ctx.db, id, now)
|
|
1324
1368
|
await createRevision(ctx.db, id, ctx.userId || null)
|
|
1325
1369
|
|
package/src/routes/context.ts
CHANGED
|
@@ -25,6 +25,7 @@ export type Capability =
|
|
|
25
25
|
| 'publish_article'
|
|
26
26
|
| 'publish_video'
|
|
27
27
|
| 'edit_own_draft'
|
|
28
|
+
| 'edit_published'
|
|
28
29
|
| 'publish_others_draft'
|
|
29
30
|
| 'submit_for_review'
|
|
30
31
|
| 'media_pipeline'
|
|
@@ -38,6 +39,8 @@ export type Capability =
|
|
|
38
39
|
// Complimentary access administration. Explicit operator decision: owner,
|
|
39
40
|
// senior_editor, and editor may grant/revoke/retry; contributor/producer may not.
|
|
40
41
|
| 'manage_complimentary_access'
|
|
42
|
+
// Read-only survey reporting. No raw respondent identity is exposed in the screen contract.
|
|
43
|
+
| 'view_survey_responses'
|
|
41
44
|
|
|
42
45
|
export interface CmsUser {
|
|
43
46
|
subject: string
|
package/src/routes/index.ts
CHANGED
|
@@ -163,6 +163,12 @@ export {
|
|
|
163
163
|
type SubscriptionsRouteConfig,
|
|
164
164
|
type SubscriptionsRouteHandlers,
|
|
165
165
|
} from './subscriptions.js'
|
|
166
|
+
// Survey reporting routes — optional storage-neutral provider, protected by role capability.
|
|
167
|
+
export {
|
|
168
|
+
createSurveyRoutes,
|
|
169
|
+
type SurveyRouteConfig,
|
|
170
|
+
type SurveyRouteHandlers,
|
|
171
|
+
} from './surveys.js'
|
|
166
172
|
// Tag management routes — GET /tags, GET /tags?q=, DELETE /tags/:id (P3 Task 9).
|
|
167
173
|
export { createTagRoutes, type TagRouteHandlers } from './tags.js'
|
|
168
174
|
// Topic management over content_items.primary_topic.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { nullProviders } from '../providers/null.js'
|
|
2
|
+
import { type CmsProviders, ProviderHttpError } from '../providers/types.js'
|
|
3
|
+
import {
|
|
4
|
+
SURVEY_ERROR_CODE_HEADER,
|
|
5
|
+
SURVEY_ERROR_DEFINITIONS,
|
|
6
|
+
type SurveyErrorCode,
|
|
7
|
+
surveyErrorBody,
|
|
8
|
+
} from '../surveys/errors.js'
|
|
9
|
+
import { SurveyCsvResultSchema, SurveyIdSchema, SurveyResultsSchema } from '../surveys/schema.js'
|
|
10
|
+
import type { Authz, Capability, RouteContext } from './context.js'
|
|
11
|
+
|
|
12
|
+
export interface SurveyRouteConfig {
|
|
13
|
+
authz: Authz
|
|
14
|
+
providers?: Partial<CmsProviders>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SurveyRouteHandlers {
|
|
18
|
+
results(ctx: RouteContext): Promise<Response>
|
|
19
|
+
csv(ctx: RouteContext): Promise<Response>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createSurveyRoutes(config: SurveyRouteConfig): SurveyRouteHandlers {
|
|
23
|
+
const providers: CmsProviders = { ...nullProviders, ...(config.providers ?? {}) }
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
async results(ctx) {
|
|
27
|
+
const denied = await requireSurveyAccess(config.authz, ctx)
|
|
28
|
+
if (denied) return withNoStoreAuthFailure(denied)
|
|
29
|
+
const surveyId = parseSurveyId(ctx)
|
|
30
|
+
if (!surveyId) return surveyErrorResponse('survey_invalid_request')
|
|
31
|
+
try {
|
|
32
|
+
const parsed = SurveyResultsSchema.safeParse(
|
|
33
|
+
await providers.surveys?.getResults({ surveyId }),
|
|
34
|
+
)
|
|
35
|
+
if (!parsed.success || parsed.data.survey.id !== surveyId) {
|
|
36
|
+
return surveyErrorResponse('survey_provider_malformed')
|
|
37
|
+
}
|
|
38
|
+
return responseJson(parsed.data)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return providerFailure(error)
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async csv(ctx) {
|
|
45
|
+
const denied = await requireSurveyAccess(config.authz, ctx)
|
|
46
|
+
if (denied) return withNoStoreAuthFailure(denied)
|
|
47
|
+
const surveyId = parseSurveyId(ctx)
|
|
48
|
+
if (!surveyId) return surveyErrorResponse('survey_invalid_request')
|
|
49
|
+
try {
|
|
50
|
+
const parsed = SurveyCsvResultSchema.safeParse(
|
|
51
|
+
await providers.surveys?.getCsv({ surveyId }),
|
|
52
|
+
)
|
|
53
|
+
if (!parsed.success) return surveyErrorResponse('survey_provider_malformed')
|
|
54
|
+
return new Response(parsed.data.content, {
|
|
55
|
+
headers: {
|
|
56
|
+
'Cache-Control': 'no-store',
|
|
57
|
+
'Content-Disposition': `attachment; filename="${safeCsvFilename(parsed.data.filename)}"`,
|
|
58
|
+
'Content-Type': 'text/csv; charset=utf-8',
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return providerFailure(error)
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function requireSurveyAccess(authz: Authz, ctx: RouteContext): Promise<Response | null> {
|
|
69
|
+
if (!authz.requireRole) return surveyErrorResponse('survey_internal_failure')
|
|
70
|
+
return authz.requireRole('view_survey_responses' satisfies Capability, ctx)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseSurveyId(ctx: RouteContext): string | null {
|
|
74
|
+
const value = ctx.params.surveyId ?? ctx.params.id
|
|
75
|
+
const parsed = SurveyIdSchema.safeParse(value)
|
|
76
|
+
return parsed.success ? parsed.data : null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function safeCsvFilename(value: string): string {
|
|
80
|
+
const basename =
|
|
81
|
+
value
|
|
82
|
+
.split(/[\\/]/)
|
|
83
|
+
.at(-1)
|
|
84
|
+
?.replace(/[\r\n]/g, '') ?? ''
|
|
85
|
+
const safe = basename
|
|
86
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
87
|
+
.replace(/^\.+/, '')
|
|
88
|
+
.replace(/-+/g, '-')
|
|
89
|
+
.slice(0, 180)
|
|
90
|
+
return safe.endsWith('.csv') ? safe : `${safe || 'survey-responses'}.csv`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function providerFailure(error: unknown): Response {
|
|
94
|
+
if (error instanceof ProviderHttpError) {
|
|
95
|
+
const mapped = ProviderFailureAllowlist.get(`${error.status}:${error.code}`)
|
|
96
|
+
if (mapped) return surveyErrorResponse(mapped)
|
|
97
|
+
}
|
|
98
|
+
return surveyErrorResponse('survey_upstream_failure')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const ProviderFailureAllowlist = new Map<string, SurveyErrorCode>([
|
|
102
|
+
['403:survey_forbidden', 'survey_forbidden'],
|
|
103
|
+
['404:survey_not_found', 'survey_not_found'],
|
|
104
|
+
['500:survey_internal_failure', 'survey_internal_failure'],
|
|
105
|
+
['502:survey_upstream_failure', 'survey_upstream_failure'],
|
|
106
|
+
['503:survey_provider_unavailable', 'survey_provider_unavailable'],
|
|
107
|
+
['503:survey_upstream_unavailable', 'survey_upstream_unavailable'],
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
function surveyErrorResponse(code: SurveyErrorCode): Response {
|
|
111
|
+
return responseJson(surveyErrorBody(code), SURVEY_ERROR_DEFINITIONS[code].status, code)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function withNoStoreAuthFailure(response: Response): Response {
|
|
115
|
+
const cloned = response.clone()
|
|
116
|
+
cloned.headers.set('Cache-Control', 'no-store')
|
|
117
|
+
if (cloned.status === 401 || cloned.status === 403) {
|
|
118
|
+
cloned.headers.set(SURVEY_ERROR_CODE_HEADER, 'survey_forbidden')
|
|
119
|
+
}
|
|
120
|
+
return cloned
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function responseJson(body: unknown, status = 200, code?: SurveyErrorCode): Response {
|
|
124
|
+
const headers = new Headers({ 'Cache-Control': 'no-store', 'Content-Type': 'application/json' })
|
|
125
|
+
if (code) headers.set(SURVEY_ERROR_CODE_HEADER, code)
|
|
126
|
+
return new Response(JSON.stringify(body), {
|
|
127
|
+
status,
|
|
128
|
+
headers,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { CmsRole } from '../schema/types.js'
|
|
2
|
+
|
|
3
|
+
export const SURVEY_RESPONSE_ROLES: ReadonlySet<CmsRole> = new Set([
|
|
4
|
+
'owner',
|
|
5
|
+
'senior_editor',
|
|
6
|
+
'editor',
|
|
7
|
+
])
|
|
8
|
+
|
|
9
|
+
export function canViewSurveyResponses(role: CmsRole | null | undefined): boolean {
|
|
10
|
+
return role != null && SURVEY_RESPONSE_ROLES.has(role)
|
|
11
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export const SURVEY_ERROR_CODE_HEADER = 'X-Growth-Labs-Survey-Error-Code'
|
|
2
|
+
|
|
3
|
+
export const SURVEY_ERROR_DEFINITIONS = {
|
|
4
|
+
survey_invalid_request: {
|
|
5
|
+
status: 400,
|
|
6
|
+
error: 'Invalid survey request.',
|
|
7
|
+
},
|
|
8
|
+
survey_forbidden: {
|
|
9
|
+
status: 403,
|
|
10
|
+
error: 'You do not have permission to view survey responses.',
|
|
11
|
+
},
|
|
12
|
+
survey_not_found: {
|
|
13
|
+
status: 404,
|
|
14
|
+
error: 'Survey not found.',
|
|
15
|
+
},
|
|
16
|
+
survey_provider_unavailable: {
|
|
17
|
+
status: 503,
|
|
18
|
+
error: 'Survey reporting is not configured for this site.',
|
|
19
|
+
},
|
|
20
|
+
survey_provider_malformed: {
|
|
21
|
+
status: 502,
|
|
22
|
+
error: 'Survey provider returned invalid data.',
|
|
23
|
+
},
|
|
24
|
+
survey_upstream_unavailable: {
|
|
25
|
+
status: 503,
|
|
26
|
+
error: 'Survey reporting is temporarily unavailable.',
|
|
27
|
+
},
|
|
28
|
+
survey_upstream_failure: {
|
|
29
|
+
status: 502,
|
|
30
|
+
error: 'Survey reporting could not be loaded.',
|
|
31
|
+
},
|
|
32
|
+
survey_internal_failure: {
|
|
33
|
+
status: 500,
|
|
34
|
+
error: 'Survey reporting could not be loaded.',
|
|
35
|
+
},
|
|
36
|
+
} as const
|
|
37
|
+
|
|
38
|
+
export type SurveyErrorCode = keyof typeof SURVEY_ERROR_DEFINITIONS
|
|
39
|
+
|
|
40
|
+
export interface SurveyErrorBody {
|
|
41
|
+
error: string
|
|
42
|
+
code: SurveyErrorCode
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isSurveyErrorCode(value: unknown): value is SurveyErrorCode {
|
|
46
|
+
return typeof value === 'string' && Object.hasOwn(SURVEY_ERROR_DEFINITIONS, value)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function surveyErrorBody(code: SurveyErrorCode): SurveyErrorBody {
|
|
50
|
+
return { error: SURVEY_ERROR_DEFINITIONS[code].error, code }
|
|
51
|
+
}
|