@growth-labs/cms 0.5.11 → 0.5.13
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 +22 -0
- package/dist/engine/index.d.ts +3 -2
- package/dist/engine/index.d.ts.map +1 -1
- package/dist/engine/index.js +3 -2
- package/dist/engine/index.js.map +1 -1
- package/dist/engine/publication.d.ts.map +1 -1
- package/dist/engine/publication.js +23 -5
- package/dist/engine/publication.js.map +1 -1
- package/dist/engine/published-content.d.ts.map +1 -1
- package/dist/engine/published-content.js +14 -5
- package/dist/engine/published-content.js.map +1 -1
- package/dist/engine/publisher.d.ts +3 -1
- package/dist/engine/publisher.d.ts.map +1 -1
- package/dist/engine/publisher.js +39 -31
- package/dist/engine/publisher.js.map +1 -1
- package/dist/engine/revisions.d.ts +3 -0
- package/dist/engine/revisions.d.ts.map +1 -1
- package/dist/engine/revisions.js +7 -1
- package/dist/engine/revisions.js.map +1 -1
- package/dist/engine/tags.d.ts +9 -2
- package/dist/engine/tags.d.ts.map +1 -1
- package/dist/engine/tags.js +32 -21
- package/dist/engine/tags.js.map +1 -1
- package/dist/engine/taxonomy.d.ts +31 -0
- package/dist/engine/taxonomy.d.ts.map +1 -0
- package/dist/engine/taxonomy.js +125 -0
- package/dist/engine/taxonomy.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/content.d.ts.map +1 -1
- package/dist/routes/content.js +98 -27
- package/dist/routes/content.js.map +1 -1
- package/dist/routes/tags.d.ts.map +1 -1
- package/dist/routes/tags.js +36 -3
- package/dist/routes/tags.js.map +1 -1
- package/dist/schema/migrations.d.ts.map +1 -1
- package/dist/schema/migrations.js +15 -1
- package/dist/schema/migrations.js.map +1 -1
- package/dist/schema/types.d.ts +1 -0
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/types.js.map +1 -1
- package/migrations/0023_content_tag_link_labels.sql +11 -0
- package/package.json +1 -1
- package/src/engine/index.ts +14 -0
- package/src/engine/publication.ts +27 -12
- package/src/engine/published-content.ts +14 -8
- package/src/engine/publisher.ts +76 -39
- package/src/engine/revisions.ts +10 -1
- package/src/engine/tags.ts +39 -19
- package/src/engine/taxonomy.ts +226 -0
- package/src/index.ts +2 -0
- package/src/routes/content.ts +91 -30
- package/src/routes/tags.ts +36 -3
- package/src/schema/migrations.ts +15 -1
- package/src/schema/types.ts +1 -0
package/src/routes/content.ts
CHANGED
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
evaluateContentBodyForPublish,
|
|
37
37
|
getContentItem,
|
|
38
38
|
getContentRelations,
|
|
39
|
+
getContentTagPairs,
|
|
39
40
|
publishContent,
|
|
40
41
|
scheduleContent,
|
|
41
42
|
unpublishContent,
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
} from '../engine/revisions.js'
|
|
51
52
|
import { applySlugRenameRedirects } from '../engine/slug-redirects.js'
|
|
52
53
|
import { softDeleteContent } from '../engine/soft-delete.js'
|
|
54
|
+
import { TagInputError } from '../engine/taxonomy.js'
|
|
53
55
|
import type { ContentStatus } from '../schema/types.js'
|
|
54
56
|
import { type ContentAction, canPerformContentAction } from './authz-matrix.js'
|
|
55
57
|
import type { CmsRouteConfig } from './config.js'
|
|
@@ -582,13 +584,7 @@ async function getContentPayload(ctx: RouteContext, type: string, id: string) {
|
|
|
582
584
|
}
|
|
583
585
|
|
|
584
586
|
async function getContentTags(ctx: RouteContext, id: string): Promise<string[]> {
|
|
585
|
-
|
|
586
|
-
.prepare(
|
|
587
|
-
'SELECT t.slug FROM content_tag_links l JOIN content_tags t ON t.id = l.tag_id WHERE l.content_id = ? ORDER BY t.label ASC',
|
|
588
|
-
)
|
|
589
|
-
.bind(id)
|
|
590
|
-
.all<{ slug: string }>()
|
|
591
|
-
return (result.results || []).map((row) => row.slug)
|
|
587
|
+
return (await getContentTagPairs(ctx.db, id)).map((row) => row.slug)
|
|
592
588
|
}
|
|
593
589
|
|
|
594
590
|
async function getMediaAssetUrls(db: D1Database, ids: string[]): Promise<Record<string, string>> {
|
|
@@ -612,6 +608,19 @@ async function readJson(ctx: RouteContext): Promise<unknown> {
|
|
|
612
608
|
return ctx.request.json().catch(() => null)
|
|
613
609
|
}
|
|
614
610
|
|
|
611
|
+
function tagInputErrorResponse(error: TagInputError): Response {
|
|
612
|
+
return json(
|
|
613
|
+
{
|
|
614
|
+
error: 'Invalid tags',
|
|
615
|
+
code: error.code,
|
|
616
|
+
limit: error.limit,
|
|
617
|
+
actual: error.actual,
|
|
618
|
+
index: error.index,
|
|
619
|
+
},
|
|
620
|
+
400,
|
|
621
|
+
)
|
|
622
|
+
}
|
|
623
|
+
|
|
615
624
|
export interface ContentRouteHandlers {
|
|
616
625
|
/** GET /content — cursor-paginated list (q, author, status, types filters; excludes deleted). */
|
|
617
626
|
list(ctx: RouteContext): Promise<Response>
|
|
@@ -1034,6 +1043,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1034
1043
|
try {
|
|
1035
1044
|
created = await createContent(ctx.db, parsed.data)
|
|
1036
1045
|
} catch (error) {
|
|
1046
|
+
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
1037
1047
|
const conflict = verifiedMediaDurationConflictResponse(error)
|
|
1038
1048
|
if (conflict) return conflict
|
|
1039
1049
|
throw error
|
|
@@ -1073,17 +1083,30 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1073
1083
|
const item = await getContentItem(ctx.db, id)
|
|
1074
1084
|
if (!item) return json({ error: 'Not found' }, 404)
|
|
1075
1085
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1086
|
+
let content: unknown
|
|
1087
|
+
let tags: string[]
|
|
1088
|
+
let relations: Awaited<ReturnType<typeof getContentRelations>>
|
|
1089
|
+
let author: { name: string | null; slug: string | null } | null
|
|
1090
|
+
try {
|
|
1091
|
+
const resolvedContent = await Promise.all([
|
|
1092
|
+
getContentPayload(ctx, item.type, id),
|
|
1093
|
+
getContentTags(ctx, id),
|
|
1094
|
+
getContentRelations(ctx.db, id),
|
|
1095
|
+
item.author_id
|
|
1096
|
+
? ctx.db
|
|
1097
|
+
.prepare('SELECT name, slug FROM authors WHERE id = ? LIMIT 1')
|
|
1098
|
+
.bind(item.author_id)
|
|
1099
|
+
.first<{ name: string | null; slug: string | null }>()
|
|
1100
|
+
: Promise.resolve(null),
|
|
1101
|
+
])
|
|
1102
|
+
content = resolvedContent[0]
|
|
1103
|
+
tags = resolvedContent[1]
|
|
1104
|
+
relations = resolvedContent[2]
|
|
1105
|
+
author = resolvedContent[3]
|
|
1106
|
+
} catch (error) {
|
|
1107
|
+
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
1108
|
+
throw error
|
|
1109
|
+
}
|
|
1087
1110
|
const contentRecord = content as Record<string, unknown> | null
|
|
1088
1111
|
const thumbnailImageId =
|
|
1089
1112
|
typeof contentRecord?.thumbnail_image_id === 'string'
|
|
@@ -1193,6 +1216,7 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1193
1216
|
try {
|
|
1194
1217
|
updated = await updateContentItem(ctx.db, id, parsed.data)
|
|
1195
1218
|
} catch (error) {
|
|
1219
|
+
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
1196
1220
|
const conflict = verifiedMediaDurationConflictResponse(error)
|
|
1197
1221
|
if (conflict) return conflict
|
|
1198
1222
|
throw error
|
|
@@ -1332,7 +1356,6 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1332
1356
|
422,
|
|
1333
1357
|
)
|
|
1334
1358
|
}
|
|
1335
|
-
|
|
1336
1359
|
if (!existing.excerpt?.trim()) {
|
|
1337
1360
|
let failureReason: string | null = null
|
|
1338
1361
|
if (!resolved.hooks.llm) {
|
|
@@ -1370,8 +1393,19 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1370
1393
|
}
|
|
1371
1394
|
}
|
|
1372
1395
|
}
|
|
1373
|
-
|
|
1374
|
-
|
|
1396
|
+
try {
|
|
1397
|
+
await getContentTagPairs(ctx.db, id)
|
|
1398
|
+
} catch (error) {
|
|
1399
|
+
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
1400
|
+
throw error
|
|
1401
|
+
}
|
|
1402
|
+
try {
|
|
1403
|
+
await publishContent(ctx.db, id, now)
|
|
1404
|
+
await createRevision(ctx.db, id, ctx.userId || null)
|
|
1405
|
+
} catch (error) {
|
|
1406
|
+
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
1407
|
+
throw error
|
|
1408
|
+
}
|
|
1375
1409
|
|
|
1376
1410
|
// Write first-party activity log row (cms_activity_log, P5 Task 3).
|
|
1377
1411
|
// Wrapped so a logging failure never breaks the publish response.
|
|
@@ -1554,10 +1588,16 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1554
1588
|
}
|
|
1555
1589
|
|
|
1556
1590
|
if (action === 'snapshot') {
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1591
|
+
let revision: Awaited<ReturnType<typeof createRevisionWithDelta>>
|
|
1592
|
+
try {
|
|
1593
|
+
revision = await createRevisionWithDelta(ctx.db, id, {
|
|
1594
|
+
createdBy: ctx.userId ?? null,
|
|
1595
|
+
tag: 'autosave',
|
|
1596
|
+
})
|
|
1597
|
+
} catch (error) {
|
|
1598
|
+
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
1599
|
+
throw error
|
|
1600
|
+
}
|
|
1561
1601
|
if (!revision) return json({ error: 'Not found' }, 404)
|
|
1562
1602
|
return json({ status: 'snapshot', revisionId: revision.id })
|
|
1563
1603
|
}
|
|
@@ -1637,10 +1677,25 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1637
1677
|
|
|
1638
1678
|
async foundryCallback(ctx) {
|
|
1639
1679
|
// HMAC-guarded machine callback — NOT behind requirePublisher.
|
|
1640
|
-
// The HMAC secret
|
|
1641
|
-
//
|
|
1642
|
-
|
|
1643
|
-
|
|
1680
|
+
// The HMAC secret arrives from ctx.env as either a plain per-Worker
|
|
1681
|
+
// secret string or a Secrets Store binding object (`{ get() }`) —
|
|
1682
|
+
// consumers bind it both ways. Never a value in code/D1. The old
|
|
1683
|
+
// string-only check silently nulled the binding shape and 500ed
|
|
1684
|
+
// every callback on Store-bound consumers.
|
|
1685
|
+
const rawHmac = ctx.env?.FOUNDRY_HMAC_SECRET as
|
|
1686
|
+
| string
|
|
1687
|
+
| { get(): Promise<string> }
|
|
1688
|
+
| undefined
|
|
1689
|
+
let hmacSecret: string | null = null
|
|
1690
|
+
if (typeof rawHmac === 'string') {
|
|
1691
|
+
hmacSecret = rawHmac
|
|
1692
|
+
} else if (rawHmac && typeof rawHmac.get === 'function') {
|
|
1693
|
+
try {
|
|
1694
|
+
hmacSecret = (await rawHmac.get()) || null
|
|
1695
|
+
} catch {
|
|
1696
|
+
hmacSecret = null
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1644
1699
|
|
|
1645
1700
|
if (!hmacSecret) {
|
|
1646
1701
|
// No secret configured — refuse with 500 (misconfiguration).
|
|
@@ -1885,7 +1940,13 @@ export function createContentRoutes(config: ContentRouteConfig): ContentRouteHan
|
|
|
1885
1940
|
const rev = ctx.params.rev
|
|
1886
1941
|
if (!rev) return json({ error: 'Missing rev' }, 400)
|
|
1887
1942
|
|
|
1888
|
-
|
|
1943
|
+
let ok: Awaited<ReturnType<typeof restoreRevision>>
|
|
1944
|
+
try {
|
|
1945
|
+
ok = await restoreRevision(ctx.db, id, rev, { createdBy: ctx.userId ?? null })
|
|
1946
|
+
} catch (error) {
|
|
1947
|
+
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
1948
|
+
throw error
|
|
1949
|
+
}
|
|
1889
1950
|
if (!ok) return json({ error: 'Not found' }, 404)
|
|
1890
1951
|
|
|
1891
1952
|
return json({ restored: true })
|
package/src/routes/tags.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { z } from 'zod'
|
|
6
6
|
import { createTag, deleteTag, getTag, listTags, renameTag, searchTags } from '../engine/tags.js'
|
|
7
|
+
import { TagInputError } from '../engine/taxonomy.js'
|
|
7
8
|
import type { CmsRouteConfig } from './config.js'
|
|
8
9
|
import { resolveConfig } from './config.js'
|
|
9
10
|
import { json, type RouteContext } from './context.js'
|
|
@@ -12,6 +13,25 @@ const TagWriteSchema = z.object({
|
|
|
12
13
|
label: z.string().min(1),
|
|
13
14
|
})
|
|
14
15
|
|
|
16
|
+
function parseLimit(value: string | null): number | undefined {
|
|
17
|
+
if (value === null) return undefined
|
|
18
|
+
const limit = Number(value)
|
|
19
|
+
return Number.isSafeInteger(limit) ? limit : undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function tagInputErrorResponse(error: TagInputError): Response {
|
|
23
|
+
return json(
|
|
24
|
+
{
|
|
25
|
+
error: 'Invalid tag',
|
|
26
|
+
code: error.code,
|
|
27
|
+
limit: error.limit,
|
|
28
|
+
actual: error.actual,
|
|
29
|
+
index: error.index,
|
|
30
|
+
},
|
|
31
|
+
400,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
15
35
|
export interface TagRouteHandlers {
|
|
16
36
|
/**
|
|
17
37
|
* GET /tags or GET /tags?q=<query>
|
|
@@ -42,8 +62,9 @@ export function createTagRoutes(config: CmsRouteConfig): TagRouteHandlers {
|
|
|
42
62
|
|
|
43
63
|
const url = new URL(ctx.request.url)
|
|
44
64
|
const q = url.searchParams.get('q')
|
|
65
|
+
const limit = parseLimit(url.searchParams.get('limit'))
|
|
45
66
|
|
|
46
|
-
const tags = q ? await searchTags(ctx.db, q) : await listTags(ctx.db)
|
|
67
|
+
const tags = q ? await searchTags(ctx.db, q, { limit }) : await listTags(ctx.db, { limit })
|
|
47
68
|
return json({ tags })
|
|
48
69
|
},
|
|
49
70
|
|
|
@@ -55,7 +76,13 @@ export function createTagRoutes(config: CmsRouteConfig): TagRouteHandlers {
|
|
|
55
76
|
if (!parsed.success) {
|
|
56
77
|
return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
|
|
57
78
|
}
|
|
58
|
-
|
|
79
|
+
let tag: Awaited<ReturnType<typeof createTag>>
|
|
80
|
+
try {
|
|
81
|
+
tag = await createTag(ctx.db, parsed.data.label)
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
84
|
+
throw error
|
|
85
|
+
}
|
|
59
86
|
if (!tag) return json({ error: 'Tag already exists or label is invalid' }, 409)
|
|
60
87
|
return json({ tag }, 201)
|
|
61
88
|
},
|
|
@@ -73,7 +100,13 @@ export function createTagRoutes(config: CmsRouteConfig): TagRouteHandlers {
|
|
|
73
100
|
if (!parsed.success) {
|
|
74
101
|
return json({ error: 'Invalid request', details: parsed.error.flatten() }, 400)
|
|
75
102
|
}
|
|
76
|
-
|
|
103
|
+
let ok: Awaited<ReturnType<typeof renameTag>>
|
|
104
|
+
try {
|
|
105
|
+
ok = await renameTag(ctx.db, id, parsed.data.label)
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error instanceof TagInputError) return tagInputErrorResponse(error)
|
|
108
|
+
throw error
|
|
109
|
+
}
|
|
77
110
|
if (!ok) return json({ error: 'Tag slug already exists or label is invalid' }, 409)
|
|
78
111
|
const tag = await getTag(ctx.db, id)
|
|
79
112
|
return json({ tag })
|
package/src/schema/migrations.ts
CHANGED
|
@@ -811,7 +811,21 @@ ALTER TABLE content_insight_dismissals
|
|
|
811
811
|
ADD COLUMN reason_code TEXT CHECK (reason_code IS NULL OR reason_code IN ('off_topic', 'already_covered', 'cant_win', 'wrong_format', 'not_now', 'other'));
|
|
812
812
|
ALTER TABLE content_insight_dismissals
|
|
813
813
|
ADD COLUMN note TEXT CHECK (note IS NULL OR length(note) <= 500);
|
|
814
|
-
`,
|
|
814
|
+
`,
|
|
815
|
+
},
|
|
816
|
+
{
|
|
817
|
+
id: '0023_content_tag_link_labels',
|
|
818
|
+
sql: `
|
|
819
|
+
ALTER TABLE content_tag_links
|
|
820
|
+
ADD COLUMN label TEXT;
|
|
821
|
+
UPDATE content_tag_links
|
|
822
|
+
SET label = (
|
|
823
|
+
SELECT label
|
|
824
|
+
FROM content_tags
|
|
825
|
+
WHERE content_tags.id = content_tag_links.tag_id
|
|
826
|
+
)
|
|
827
|
+
WHERE label IS NULL;
|
|
828
|
+
`,
|
|
815
829
|
},
|
|
816
830
|
]
|
|
817
831
|
|