@beechcms/api 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/assets/dashboard/assets/index-CH13idU1.js +554 -0
  2. package/assets/dashboard/index.html +1 -1
  3. package/package.json +2 -2
  4. package/src/auth/bcrypt-hash-provider.ts +20 -0
  5. package/src/auth/constants.ts +3 -3
  6. package/src/auth/generate-refresh-token.test.ts +19 -0
  7. package/src/auth/hash-provider.test.ts +46 -0
  8. package/src/auth/in-memory-hash-provider.ts +13 -0
  9. package/src/auth/jose-token-service.ts +55 -0
  10. package/src/auth/login.test.ts +92 -0
  11. package/src/auth/login.ts +15 -32
  12. package/src/auth/refresh.ts +0 -122
  13. package/src/auth/static-token-service.ts +18 -0
  14. package/src/auth/token-service.test.ts +82 -0
  15. package/src/factory.ts +70 -78
  16. package/src/features/content/handlers/create.ts +14 -10
  17. package/src/features/content/handlers/delete.ts +13 -9
  18. package/src/features/content/handlers/update.ts +13 -9
  19. package/src/features/draft/draft.handler.ts +23 -12
  20. package/src/features/notifications/notifications.handler.ts +25 -54
  21. package/src/features/password-reset/request.ts +17 -41
  22. package/src/features/password-reset/reset.ts +18 -54
  23. package/src/features/rotate-field/rotate-field.handler.ts +15 -19
  24. package/src/features/schema/schema.handler.ts +1 -1
  25. package/src/features/settings/settings.handler.ts +62 -175
  26. package/src/features/setup/index.ts +12 -17
  27. package/src/features/stats/stats.handler.ts +110 -138
  28. package/src/middleware/auth-providers.middleware.ts +32 -0
  29. package/src/middleware/observability.middleware.ts +52 -0
  30. package/src/middleware/rate-limit.middleware.ts +41 -0
  31. package/src/middleware/repository.middleware.ts +41 -5
  32. package/src/middleware.ts +15 -35
  33. package/src/public/public-add.ts +5 -15
  34. package/src/public/public-edit.ts +4 -3
  35. package/src/public/public-read.ts +3 -3
  36. package/src/public/public-routes.ts +2 -2
  37. package/src/public/query-builder.test.ts +220 -0
  38. package/src/public/rate-limit-middleware.ts +7 -19
  39. package/src/rate-limit/cloudflare-rate-limiter.test.ts +26 -0
  40. package/src/rate-limit/cloudflare-rate-limiter.ts +11 -0
  41. package/src/rate-limit/in-memory-rate-limiter.test.ts +33 -0
  42. package/src/rate-limit/in-memory-rate-limiter.ts +13 -0
  43. package/src/rate-limit/no-op-rate-limiter.test.ts +18 -0
  44. package/src/rate-limit/no-op-rate-limiter.ts +7 -0
  45. package/src/search-utils.test.ts +207 -0
  46. package/src/search-utils.ts +18 -1
  47. package/src/search.ts +24 -35
  48. package/src/shared/apply-policies.test.ts +77 -0
  49. package/src/shared/background-notification-service.test.ts +58 -0
  50. package/src/shared/background-notification-service.ts +48 -0
  51. package/src/shared/content-utils.test.ts +161 -0
  52. package/src/shared/content.repository.d1.test.ts +312 -0
  53. package/src/shared/d1-activity-log.repository.test.ts +136 -0
  54. package/src/shared/d1-activity-log.repository.ts +101 -0
  55. package/src/shared/d1-activity-logger.test.ts +82 -0
  56. package/src/shared/d1-activity-logger.ts +63 -0
  57. package/src/shared/d1-analytics.repository.test.ts +74 -0
  58. package/src/shared/d1-analytics.repository.ts +81 -0
  59. package/src/shared/d1-content-scan.repository.ts +29 -0
  60. package/src/shared/d1-notification.repository.test.ts +124 -0
  61. package/src/shared/d1-notification.repository.ts +114 -0
  62. package/src/shared/d1-password-reset-token.repository.test.ts +77 -0
  63. package/src/shared/d1-password-reset-token.repository.ts +52 -0
  64. package/src/shared/d1-search.repository.test.ts +83 -0
  65. package/src/shared/d1-search.repository.ts +84 -0
  66. package/src/shared/d1-session.repository.test.ts +121 -0
  67. package/src/shared/d1-session.repository.ts +98 -0
  68. package/src/shared/d1-user.repository.test.ts +147 -0
  69. package/src/shared/d1-user.repository.ts +109 -0
  70. package/src/shared/d1-widget.repository.test.ts +217 -0
  71. package/src/shared/d1-widget.repository.ts +337 -0
  72. package/src/shared/fixed-clock.ts +21 -0
  73. package/src/shared/idempotency.repository.d1.test.ts +79 -0
  74. package/src/shared/in-memory-activity-logger.ts +15 -0
  75. package/src/shared/in-memory-notification-service.ts +15 -0
  76. package/src/shared/media.repository.d1.test.ts +103 -0
  77. package/src/shared/media.repository.d1.ts +1 -1
  78. package/src/shared/request-utils.ts +22 -0
  79. package/src/shared/sequential-id-generator.ts +22 -0
  80. package/src/shared/system-stats.repository.d1.test.ts +54 -0
  81. package/src/types.ts +20 -3
  82. package/src/upload.ts +14 -7
  83. package/src/widget.ts +112 -253
  84. package/assets/dashboard/assets/index-FQ6JhvRH.js +0 -554
  85. package/src/shared/activity-logger.ts +0 -79
  86. package/src/shared/notification-service.ts +0 -56
@@ -0,0 +1,207 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { encodeCursor, decodeCursor, buildFtsQuery, mapFtsRow } from './search-utils'
3
+ import type { Seed } from '@beechcms/core'
4
+
5
+ const TEXT_SEED = {
6
+ slug: 'articoli',
7
+ displayNameAlias: 'title',
8
+ branches: [
9
+ { id: 'br_01', alias: 'title', type: 'text', policies: { search: true } },
10
+ ],
11
+ } as unknown as Seed
12
+
13
+ const SECOND_SEED = {
14
+ slug: 'team',
15
+ displayNameAlias: 'name',
16
+ branches: [
17
+ { id: 'br_01', alias: 'name', type: 'text', policies: { search: true } },
18
+ ],
19
+ } as unknown as Seed
20
+
21
+ const NO_FTS_SEED = {
22
+ slug: 'prodotti',
23
+ displayNameAlias: 'nome',
24
+ branches: [
25
+ { id: 'br_01', alias: 'price', type: 'number' },
26
+ ],
27
+ } as unknown as Seed
28
+
29
+ // ─── encodeCursor / decodeCursor ─────────────────────────────────────────────
30
+
31
+ describe('encodeCursor / decodeCursor', () => {
32
+ it('roundtrip preserves rank and entryId', () => {
33
+ const cursor = encodeCursor(-1.23456, 'entry-abc')
34
+ const decoded = decodeCursor(cursor)
35
+ expect(decoded?.rank).toBeCloseTo(-1.23456)
36
+ expect(decoded?.entryId).toBe('entry-abc')
37
+ })
38
+
39
+ it('entryId without colons roundtrips correctly (UUID-style IDs)', () => {
40
+ const entryId = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
41
+ const cursor = encodeCursor(-3.7, entryId)
42
+ const decoded = decodeCursor(cursor)
43
+ expect(decoded?.rank).toBeCloseTo(-3.7)
44
+ expect(decoded?.entryId).toBe(entryId)
45
+ })
46
+
47
+ it('returns null for invalid base64', () => {
48
+ expect(decodeCursor('!!!not-base64!!!')).toBeNull()
49
+ })
50
+
51
+ it('returns null when decoded string has no colon separator', () => {
52
+ expect(decodeCursor(btoa('noseparator'))).toBeNull()
53
+ })
54
+ })
55
+
56
+ // ─── buildFtsQuery ───────────────────────────────────────────────────────────
57
+
58
+ describe('buildFtsQuery', () => {
59
+ it('returns empty-result query when no seed has a searchable FTS branch', () => {
60
+ const result = buildFtsQuery(
61
+ { q: 'hello', schemaSlug: null, status: null, limit: 20, cursor: null },
62
+ [NO_FTS_SEED],
63
+ )
64
+ expect(result.sql).toContain('WHERE 1=0')
65
+ expect(result.binds).toHaveLength(0)
66
+ expect(result.countSql).toContain('SELECT 0 as total')
67
+ })
68
+
69
+ it('throws EMPTY_QUERY when all terms are stripped or too short (single chars)', () => {
70
+ expect(() =>
71
+ buildFtsQuery({ q: 'a b', schemaSlug: null, status: null, limit: 20, cursor: null }, [TEXT_SEED]),
72
+ ).toThrow('EMPTY_QUERY')
73
+ })
74
+
75
+ it('generates a query referencing the seed FTS and content tables', () => {
76
+ const result = buildFtsQuery(
77
+ { q: 'hello', schemaSlug: null, status: null, limit: 20, cursor: null },
78
+ [TEXT_SEED],
79
+ )
80
+ expect(result.sql).toContain('fts_articoli')
81
+ expect(result.sql).toContain('content_articoli')
82
+ expect(result.sql).toContain('LIMIT ?')
83
+ expect(result.binds.at(-1)).toBe(21) // limit + 1 for has-more detection
84
+ })
85
+
86
+ it('UNION ALLs multiple seeds when no schemaSlug filter is set', () => {
87
+ const result = buildFtsQuery(
88
+ { q: 'test', schemaSlug: null, status: null, limit: 10, cursor: null },
89
+ [TEXT_SEED, SECOND_SEED],
90
+ )
91
+ expect(result.sql).toContain('UNION ALL')
92
+ expect(result.sql).toContain('fts_articoli')
93
+ expect(result.sql).toContain('fts_team')
94
+ })
95
+
96
+ it('limits query to the requested schemaSlug when provided', () => {
97
+ const result = buildFtsQuery(
98
+ { q: 'test', schemaSlug: 'articoli', status: null, limit: 20, cursor: null },
99
+ [TEXT_SEED, SECOND_SEED],
100
+ )
101
+ expect(result.sql).toContain('fts_articoli')
102
+ expect(result.sql).not.toContain('fts_team')
103
+ })
104
+
105
+ it('adds status filter to WHERE clause and bind values', () => {
106
+ const result = buildFtsQuery(
107
+ { q: 'hello', schemaSlug: null, status: 'published', limit: 20, cursor: null },
108
+ [TEXT_SEED],
109
+ )
110
+ expect(result.sql).toContain('ce.status = ?')
111
+ expect(result.binds).toContain('published')
112
+ })
113
+
114
+ it('adds cursor-based pagination condition when cursor is valid', () => {
115
+ const cursor = encodeCursor(-1.5, 'entry-123')
116
+ const result = buildFtsQuery(
117
+ { q: 'hello', schemaSlug: null, status: null, limit: 20, cursor },
118
+ [TEXT_SEED],
119
+ )
120
+ expect(result.sql).toContain('bm25')
121
+ expect(result.binds).toContain('entry-123')
122
+ })
123
+
124
+ it('ignores an invalid cursor and produces no pagination condition', () => {
125
+ const result = buildFtsQuery(
126
+ { q: 'hello', schemaSlug: null, status: null, limit: 20, cursor: 'bad-cursor' },
127
+ [TEXT_SEED],
128
+ )
129
+ expect(result.binds).not.toContain('entry-123')
130
+ })
131
+
132
+ it('countSql wraps each seed count in a SUM', () => {
133
+ const result = buildFtsQuery(
134
+ { q: 'test', schemaSlug: null, status: null, limit: 10, cursor: null },
135
+ [TEXT_SEED, SECOND_SEED],
136
+ )
137
+ expect(result.countSql).toContain('SUM')
138
+ expect(result.countSql).toContain('fts_articoli')
139
+ expect(result.countSql).toContain('fts_team')
140
+ })
141
+
142
+ it('count binds do not include the limit+1 sentinel', () => {
143
+ const result = buildFtsQuery(
144
+ { q: 'hello', schemaSlug: null, status: null, limit: 5, cursor: null },
145
+ [TEXT_SEED],
146
+ )
147
+ expect(result.countBinds).not.toContain(6) // limit + 1 must not appear in count binds
148
+ })
149
+
150
+ it('single-character terms (length < 2) are filtered out', () => {
151
+ // 'a b c' — single-char terms discarded; result depends on remaining terms
152
+ expect(() =>
153
+ buildFtsQuery({ q: 'a b c', schemaSlug: null, status: null, limit: 20, cursor: null }, [TEXT_SEED]),
154
+ ).toThrow('EMPTY_QUERY')
155
+ })
156
+
157
+ it('numeric terms are quoted without prefix expansion', () => {
158
+ const result = buildFtsQuery(
159
+ { q: '2024', schemaSlug: null, status: null, limit: 20, cursor: null },
160
+ [TEXT_SEED],
161
+ )
162
+ expect(result.binds[0]).toContain('"2024"')
163
+ })
164
+ })
165
+
166
+ // ─── mapFtsRow ───────────────────────────────────────────────────────────────
167
+
168
+ describe('mapFtsRow', () => {
169
+ it('maps all FtsRow fields to SearchResultItem', () => {
170
+ const row = {
171
+ entry_id: 'e1', schema_slug: 'articoli', slug: 'my-post',
172
+ status: 'published', title: 'My Post', excerpt: 'A snippet', rank: -1,
173
+ }
174
+ const result = mapFtsRow(row)
175
+ expect(result.id).toBe('e1')
176
+ expect(result.schema_slug).toBe('articoli')
177
+ expect(result.slug).toBe('my-post')
178
+ expect(result.status).toBe('published')
179
+ expect(result.title).toBe('My Post')
180
+ expect(result.excerpt).toBe('A snippet')
181
+ expect(result.data).toEqual({})
182
+ })
183
+
184
+ it('strips HTML tags from excerpt but preserves <mark> and </mark>', () => {
185
+ const row = {
186
+ entry_id: 'e1', schema_slug: 'a', slug: null, status: 'draft',
187
+ title: null, excerpt: '<p>A <mark>word</mark> here</p>', rank: 0,
188
+ }
189
+ expect(mapFtsRow(row).excerpt).toBe('A <mark>word</mark> here')
190
+ })
191
+
192
+ it('collapses multiple whitespace characters in excerpt', () => {
193
+ const row = {
194
+ entry_id: 'e1', schema_slug: 'a', slug: null, status: 'draft',
195
+ title: null, excerpt: '<p> lots of space </p>', rank: 0,
196
+ }
197
+ expect(mapFtsRow(row).excerpt).toBe('lots of space')
198
+ })
199
+
200
+ it('returns empty string for null title', () => {
201
+ const row = {
202
+ entry_id: 'e1', schema_slug: 'a', slug: null, status: 'draft',
203
+ title: null, excerpt: '', rank: 0,
204
+ }
205
+ expect(mapFtsRow(row).title).toBe('')
206
+ })
207
+ })
@@ -2,7 +2,7 @@
2
2
  // Pure functions — zero Hono dependencies, importable from Vitest.
3
3
  // v0.4.0: FTS is per-seed (fts_{slug}), joined with content_{slug} for metadata.
4
4
 
5
- import type { Seed } from "@beechcms/core"
5
+ import type { Seed, SearchResultRow } from "@beechcms/core"
6
6
 
7
7
  // ─── Types ───────────────────────────────────────────────────────────────────
8
8
 
@@ -190,3 +190,20 @@ export function mapFtsRow(row: FtsRow): SearchResultItem {
190
190
  data: {},
191
191
  }
192
192
  }
193
+
194
+ /**
195
+ * Maps a repository-shaped SearchResultRow (camelCase) to the wire-format
196
+ * SearchResultItem returned by the /api/search route. Keeps the HTML
197
+ * stripping behaviour previously inlined inside mapFtsRow.
198
+ */
199
+ export function mapSearchResultRow(row: SearchResultRow): SearchResultItem {
200
+ return {
201
+ id: row.entryId,
202
+ schema_slug: row.schemaSlug,
203
+ slug: row.slug,
204
+ status: row.status,
205
+ title: row.title ?? "",
206
+ excerpt: stripHtmlPreserveMark(row.excerpt ?? ""),
207
+ data: {},
208
+ }
209
+ }
package/src/search.ts CHANGED
@@ -4,69 +4,58 @@ import { Hono } from "hono"
4
4
  import type { Env, Variables } from "./types"
5
5
  import { authMiddleware } from "./middleware"
6
6
  import {
7
- buildFtsQuery,
8
7
  encodeCursor,
9
- mapFtsRow,
10
- type FtsRow,
8
+ mapSearchResultRow,
11
9
  type SearchResponse,
12
10
  } from "./search-utils"
13
11
 
14
12
  export const searchRouter = new Hono<{ Bindings: Env; Variables: Variables }>()
15
13
 
16
- searchRouter.use("*", async (c, next) => {
17
- return authMiddleware(c.env.JWT_SECRET, {
18
- issuer: c.env.JWT_ISSUER,
19
- audience: c.env.JWT_AUDIENCE,
20
- })(c, next)
21
- })
14
+ searchRouter.use("*", authMiddleware())
22
15
 
23
16
  // GET /api/search?q=...&schema_slug=...&status=...&limit=20&cursor=...
24
17
  searchRouter.get("/", async (c) => {
25
- const q = c.req.query("q")?.trim() ?? ""
18
+ const queryText = c.req.query("q")?.trim() ?? ""
26
19
  const schemaSlug = c.req.query("schema_slug") ?? null
27
20
  const status = c.req.query("status") ?? null
28
21
  const rawLimit = parseInt(c.req.query("limit") ?? "20", 10)
29
22
  const limit = Math.min(Math.max(rawLimit, 1), 50)
30
23
  const cursor = c.req.query("cursor") ?? null
31
24
 
32
- if (q.length < 2) {
25
+ if (queryText.length < 2) {
33
26
  return c.json({ error: "Il parametro 'q' deve avere almeno 2 caratteri." }, 400)
34
27
  }
35
28
 
36
- const seeds = Object.values(c.get('seedRegistry'))
29
+ const seeds = c.get('seedRegistry').all()
30
+ const searchRepository = c.get('searchRepository')
37
31
 
38
- let queryParts: ReturnType<typeof buildFtsQuery>
39
- try {
40
- queryParts = buildFtsQuery({ q, schemaSlug, status, limit, cursor }, seeds)
41
- } catch (e) {
42
- if ((e as Error).message === "EMPTY_QUERY") {
43
- return c.json({ items: [], nextCursor: null, total: 0 } satisfies SearchResponse)
44
- }
45
- throw e
32
+ const queryOptions = {
33
+ queryText,
34
+ schemaSlug,
35
+ statusFilter: status,
36
+ limit,
37
+ cursor,
46
38
  }
39
+ const countOptions = { queryText, schemaSlug, statusFilter: status }
47
40
 
48
- const { sql, binds, countSql, countBinds } = queryParts
49
-
50
- const [ftsResult, countResult] = await Promise.all([
51
- c.env.DB.prepare(sql).bind(...binds).all<FtsRow>(),
52
- c.env.DB.prepare(countSql).bind(...countBinds).first<{ total: number }>(),
41
+ const [rawRows, countResult] = await Promise.all([
42
+ searchRepository.search(queryOptions, seeds),
43
+ searchRepository.count(countOptions, seeds),
53
44
  ])
54
45
 
55
- const rows = ftsResult.results ?? []
56
- const total = countResult?.total ?? 0
57
-
58
- const hasMore = rows.length > limit
59
- const pageRows = hasMore ? rows.slice(0, limit) : rows
46
+ const hasMore = rawRows.length > limit
47
+ const pageRows = hasMore ? rawRows.slice(0, limit) : rawRows
60
48
 
61
- const nextCursor = hasMore
62
- ? encodeCursor(pageRows.at(-1)!.rank, pageRows.at(-1)!.entry_id)
49
+ const lastRow = pageRows.at(-1)
50
+ const nextCursor = hasMore && lastRow
51
+ ? encodeCursor(lastRow.rank, lastRow.entryId)
63
52
  : null
64
53
 
65
54
  if (pageRows.length === 0) {
66
- return c.json({ items: [], nextCursor: null, total } satisfies SearchResponse)
55
+ return c.json({ items: [], nextCursor: null, total: countResult.total } satisfies SearchResponse)
67
56
  }
68
57
 
69
- const items = pageRows.map(row => mapFtsRow(row))
58
+ const items = pageRows.map(mapSearchResultRow)
70
59
 
71
- return c.json({ items, nextCursor, total } satisfies SearchResponse)
60
+ return c.json({ items, nextCursor, total: countResult.total } satisfies SearchResponse)
72
61
  })
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { applyPrivacy, applyVisibility, PrivacyPolicyError } from './apply-policies'
3
+ import type { Seed } from '@beechcms/core'
4
+
5
+ function makeSeed(branches: any[]): Seed {
6
+ return { slug: 'test', displayNameAlias: 'title', branches } as unknown as Seed
7
+ }
8
+
9
+ // ─── applyPrivacy ─────────────────────────────────────────────────────────────
10
+
11
+ describe('applyPrivacy', () => {
12
+ it('passes through fields with no privacy policy (default: public)', async () => {
13
+ const seed = makeSeed([{ id: 'br_01', alias: 'title', type: 'text' }])
14
+ const result = await applyPrivacy({ title: 'Hello' }, seed)
15
+ expect(result.title).toBe('Hello')
16
+ })
17
+
18
+ it('hashes the value when branch privacy is "hash"', async () => {
19
+ const seed = makeSeed([{ id: 'br_01', alias: 'email', type: 'text', policies: { privacy: 'hash' } }])
20
+ const result = await applyPrivacy({ email: 'user@test.com' }, seed)
21
+ // sha256 produces a 64-char hex string
22
+ expect(typeof result.email).toBe('string')
23
+ expect((result.email as string).length).toBe(64)
24
+ expect(result.email).not.toBe('user@test.com')
25
+ })
26
+
27
+ it('leaves null/undefined values unhashed even when privacy is "hash"', async () => {
28
+ const seed = makeSeed([{ id: 'br_01', alias: 'email', type: 'text', policies: { privacy: 'hash' } }])
29
+ const result = await applyPrivacy({ email: null }, seed)
30
+ expect(result.email).toBeNull()
31
+ })
32
+
33
+ it('throws PrivacyPolicyError for "encrypt" privacy (not yet implemented)', async () => {
34
+ const seed = makeSeed([{ id: 'br_01', alias: 'secret', type: 'text', policies: { privacy: 'encrypt' } }])
35
+ await expect(applyPrivacy({ secret: 'value' }, seed)).rejects.toBeInstanceOf(PrivacyPolicyError)
36
+ })
37
+
38
+ it('passes through fields not present in the seed branches unchanged', async () => {
39
+ const seed = makeSeed([{ id: 'br_01', alias: 'title', type: 'text' }])
40
+ const result = await applyPrivacy({ title: 'Hi', extraField: 'extra' }, seed)
41
+ expect(result.extraField).toBe('extra')
42
+ })
43
+ })
44
+
45
+ // ─── applyVisibility ──────────────────────────────────────────────────────────
46
+
47
+ describe('applyVisibility', () => {
48
+ it('includes fields with default (visible) visibility', () => {
49
+ const seed = makeSeed([{ id: 'br_01', alias: 'title', type: 'text' }])
50
+ const result = applyVisibility({ title: 'Hello' }, seed)
51
+ expect(result.title).toBe('Hello')
52
+ })
53
+
54
+ it('omits fields with visibility "hidden"', () => {
55
+ const seed = makeSeed([{ id: 'br_01', alias: 'internal', type: 'text', policies: { visibility: 'hidden' } }])
56
+ const result = applyVisibility({ internal: 'secret' }, seed)
57
+ expect(result).not.toHaveProperty('internal')
58
+ })
59
+
60
+ it('masks non-empty string fields with visibility "masked"', () => {
61
+ const seed = makeSeed([{ id: 'br_01', alias: 'password', type: 'text', policies: { visibility: 'masked' } }])
62
+ const result = applyVisibility({ password: 'secret123' }, seed)
63
+ expect(result.password).toBe('••••••••')
64
+ })
65
+
66
+ it('returns null for empty string fields with visibility "masked"', () => {
67
+ const seed = makeSeed([{ id: 'br_01', alias: 'password', type: 'text', policies: { visibility: 'masked' } }])
68
+ const result = applyVisibility({ password: '' }, seed)
69
+ expect(result.password).toBeNull()
70
+ })
71
+
72
+ it('passes through fields not present in the seed branches', () => {
73
+ const seed = makeSeed([{ id: 'br_01', alias: 'title', type: 'text' }])
74
+ const result = applyVisibility({ title: 'Hi', extraField: 'pass' }, seed)
75
+ expect(result.extraField).toBe('pass')
76
+ })
77
+ })
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { BackgroundNotificationService } from './background-notification-service'
3
+ import type { INotificationRepository, NotificationRecord } from '@beechcms/core'
4
+
5
+ function makeRepository(opts: { createShouldThrow?: boolean } = {}): INotificationRepository {
6
+ return {
7
+ list: vi.fn().mockResolvedValue([]),
8
+ stats: vi.fn().mockResolvedValue({ totalCount: 0, latestCreatedAt: 0, readCount: 0 }),
9
+ create: opts.createShouldThrow
10
+ ? vi.fn().mockRejectedValue(new Error('db down'))
11
+ : vi.fn().mockResolvedValue('generated-id'),
12
+ markRead: vi.fn(),
13
+ markUnread: vi.fn(),
14
+ markAllRead: vi.fn(),
15
+ delete: vi.fn(),
16
+ } as unknown as INotificationRepository & {
17
+ create: ReturnType<typeof vi.fn>
18
+ }
19
+ }
20
+
21
+ describe('BackgroundNotificationService', () => {
22
+ it('delegates persistence to the repository with the provided fields', async () => {
23
+ const repo = makeRepository()
24
+ await new BackgroundNotificationService(repo).notify({
25
+ title: 'Hello',
26
+ message: 'World',
27
+ type: 'success',
28
+ })
29
+ expect(repo.create).toHaveBeenCalledWith({
30
+ title: 'Hello',
31
+ message: 'World',
32
+ type: 'success',
33
+ })
34
+ })
35
+
36
+ it('defaults to type "info" when none is provided', async () => {
37
+ const repo = makeRepository()
38
+ await new BackgroundNotificationService(repo).notify({ title: 'T', message: 'M' })
39
+ expect(repo.create).toHaveBeenCalledWith({ title: 'T', message: 'M', type: 'info' })
40
+ })
41
+
42
+ it('delegates to the background scheduler when provided', () => {
43
+ const repo = makeRepository()
44
+ const schedule = vi.fn()
45
+ new BackgroundNotificationService(repo, schedule).notify({ title: 'T', message: 'M' })
46
+ expect(schedule).toHaveBeenCalledTimes(1)
47
+ expect(schedule.mock.calls[0][0]).toBeInstanceOf(Promise)
48
+ })
49
+
50
+ it('never throws to the caller when the repository write fails', async () => {
51
+ const repo = makeRepository({ createShouldThrow: true })
52
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
53
+ const service = new BackgroundNotificationService(repo)
54
+ await expect(service.notify({ title: 'T', message: 'M' })).resolves.toBeUndefined()
55
+ expect(consoleSpy).toHaveBeenCalled()
56
+ consoleSpy.mockRestore()
57
+ })
58
+ })
@@ -0,0 +1,48 @@
1
+ import type {
2
+ INotificationService,
3
+ CreateNotificationInput,
4
+ INotificationRepository,
5
+ NotificationType,
6
+ } from '@beechcms/core'
7
+
8
+ const DEFAULT_NOTIFICATION_TYPE: NotificationType = 'info'
9
+
10
+ type ScheduleBackgroundTask = (task: Promise<unknown>) => void
11
+
12
+ /**
13
+ * Production implementation of {@link INotificationService}.
14
+ *
15
+ * Delegates persistence to the injected {@link INotificationRepository}.
16
+ * When `scheduleBackgroundTask` is provided (wired to
17
+ * `c.executionCtx.waitUntil`), the repository write runs after the response
18
+ * is flushed so the public-API request that triggered it is never delayed.
19
+ */
20
+ export class BackgroundNotificationService implements INotificationService {
21
+ constructor(
22
+ private readonly notificationRepository: INotificationRepository,
23
+ private readonly scheduleBackgroundTask?: ScheduleBackgroundTask
24
+ ) {}
25
+
26
+ notify(input: CreateNotificationInput): Promise<void> | void {
27
+ const persistPromise = this.runPersist(input)
28
+
29
+ if (this.scheduleBackgroundTask) {
30
+ this.scheduleBackgroundTask(persistPromise)
31
+ return
32
+ }
33
+
34
+ return persistPromise
35
+ }
36
+
37
+ private async runPersist(input: CreateNotificationInput): Promise<void> {
38
+ try {
39
+ await this.notificationRepository.create({
40
+ title: input.title,
41
+ message: input.message,
42
+ type: input.type ?? DEFAULT_NOTIFICATION_TYPE,
43
+ })
44
+ } catch (error) {
45
+ console.error('BackgroundNotificationService: failed to create notification', error)
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { rowToApiData, rowToEntry, buildInsertBindings, buildUpdateBindings, hasDraft } from './content-utils'
3
+ import type { Seed } from '@beechcms/core'
4
+
5
+ const SEED = {
6
+ slug: 'articoli',
7
+ displayNameAlias: 'title',
8
+ allowDrafts: true,
9
+ branches: [
10
+ { id: 'br_01', alias: 'title', type: 'text' },
11
+ { id: 'br_02', alias: 'body', type: 'text' },
12
+ ],
13
+ } as unknown as Seed
14
+
15
+ const NO_DRAFT_SEED = {
16
+ slug: 'prodotti',
17
+ displayNameAlias: 'nome',
18
+ allowDrafts: false,
19
+ branches: [
20
+ { id: 'br_01', alias: 'nome', type: 'text' },
21
+ ],
22
+ } as unknown as Seed
23
+
24
+ // ─── rowToApiData ─────────────────────────────────────────────────────────────
25
+
26
+ describe('rowToApiData', () => {
27
+ it('returns an object keyed by branch aliases with deserialized values', () => {
28
+ const result = rowToApiData(SEED, { title: 'Hello', body: 'World' })
29
+ expect(result).toHaveProperty('title', 'Hello')
30
+ expect(result).toHaveProperty('body', 'World')
31
+ })
32
+
33
+ it('uses null for missing branch values', () => {
34
+ const result = rowToApiData(SEED, {})
35
+ expect(result.title).toBeNull()
36
+ expect(result.body).toBeNull()
37
+ })
38
+
39
+ it('only includes keys for branches defined in the seed', () => {
40
+ const result = rowToApiData(SEED, { title: 'Hi', unknownCol: 'ignored' })
41
+ expect(Object.keys(result)).toEqual(['title', 'body'])
42
+ expect(result).not.toHaveProperty('unknownCol')
43
+ })
44
+ })
45
+
46
+ // ─── rowToEntry ───────────────────────────────────────────────────────────────
47
+
48
+ describe('rowToEntry', () => {
49
+ it('maps a DB row to a ContentEntry with correct system fields', () => {
50
+ const row = {
51
+ id: 'entry-1', slug: 'my-post', status: 'published',
52
+ title: 'Hello', body: 'World', created_at: 1000, updated_at: 2000,
53
+ }
54
+ const entry = rowToEntry(SEED, row)
55
+ expect(entry.id).toBe('entry-1')
56
+ expect(entry.schema_slug).toBe('articoli')
57
+ expect(entry.slug).toBe('my-post')
58
+ expect(entry.status).toBe('published')
59
+ expect(entry.created_at).toBe(1000)
60
+ expect(entry.updated_at).toBe(2000)
61
+ expect(entry.hasPendingDraft).toBe(false)
62
+ })
63
+
64
+ it('defaults hasPendingDraft to false when not provided', () => {
65
+ const entry = rowToEntry(SEED, { id: 'e1', slug: null, status: 'draft', title: null, body: null, created_at: null, updated_at: null })
66
+ expect(entry.hasPendingDraft).toBe(false)
67
+ })
68
+
69
+ it('passes through hasPendingDraft when explicitly set to true', () => {
70
+ const entry = rowToEntry(SEED, { id: 'e1', slug: null, status: 'draft', title: null, body: null }, true)
71
+ expect(entry.hasPendingDraft).toBe(true)
72
+ })
73
+
74
+ it('defaults slug to null and status to draft for missing values', () => {
75
+ const entry = rowToEntry(SEED, { id: 'e1' })
76
+ expect(entry.slug).toBeNull()
77
+ expect(entry.status).toBe('draft')
78
+ })
79
+ })
80
+
81
+ // ─── buildInsertBindings ──────────────────────────────────────────────────────
82
+
83
+ describe('buildInsertBindings', () => {
84
+ it('returns cols, placeholders, and bindings for each matching branch alias', () => {
85
+ const { cols, placeholders, bindings } = buildInsertBindings(SEED, { title: 'Hi', body: 'There' })
86
+ expect(cols).toContain('title')
87
+ expect(cols).toContain('body')
88
+ expect(placeholders).toHaveLength(2)
89
+ expect(placeholders.every(p => p === '?')).toBe(true)
90
+ expect(bindings).toHaveLength(2)
91
+ })
92
+
93
+ it('omits branches not present in the payload', () => {
94
+ const { cols, bindings } = buildInsertBindings(SEED, { title: 'Only title' })
95
+ expect(cols).toEqual(['title'])
96
+ expect(bindings).toHaveLength(1)
97
+ })
98
+
99
+ it('returns empty arrays when payload has no matching aliases', () => {
100
+ const { cols, placeholders, bindings } = buildInsertBindings(SEED, { unknownField: 'x' })
101
+ expect(cols).toHaveLength(0)
102
+ expect(placeholders).toHaveLength(0)
103
+ expect(bindings).toHaveLength(0)
104
+ })
105
+ })
106
+
107
+ // ─── buildUpdateBindings ──────────────────────────────────────────────────────
108
+
109
+ describe('buildUpdateBindings', () => {
110
+ it('returns a SET clause and bindings for each matching branch alias', () => {
111
+ const { setClause, bindings } = buildUpdateBindings(SEED, { title: 'New', body: 'Content' })
112
+ expect(setClause).toContain('title = ?')
113
+ expect(setClause).toContain('body = ?')
114
+ expect(bindings).toHaveLength(2)
115
+ })
116
+
117
+ it('builds a single-field SET clause', () => {
118
+ const { setClause, bindings } = buildUpdateBindings(SEED, { title: 'Updated' })
119
+ expect(setClause).toBe('title = ?')
120
+ expect(bindings).toHaveLength(1)
121
+ })
122
+
123
+ it('returns empty setClause and bindings for non-matching payload', () => {
124
+ const { setClause, bindings } = buildUpdateBindings(SEED, { ghost: 'field' })
125
+ expect(setClause).toBe('')
126
+ expect(bindings).toHaveLength(0)
127
+ })
128
+ })
129
+
130
+ // ─── hasDraft ─────────────────────────────────────────────────────────────────
131
+
132
+ describe('hasDraft', () => {
133
+ function makeMockDb(firstResult: unknown) {
134
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
135
+ const bindMock = vi.fn(() => ({ first: firstMock }))
136
+ const prepareMock = vi.fn(() => ({ bind: bindMock }))
137
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock }
138
+ }
139
+
140
+ it('returns false immediately when seed does not allow drafts', async () => {
141
+ const { db, prepareMock } = makeMockDb(null)
142
+ expect(await hasDraft(db, NO_DRAFT_SEED, 'e1')).toBe(false)
143
+ expect(prepareMock).not.toHaveBeenCalled()
144
+ })
145
+
146
+ it('returns true when a draft row exists', async () => {
147
+ const { db } = makeMockDb({ 1: 1 })
148
+ expect(await hasDraft(db, SEED, 'entry-1')).toBe(true)
149
+ })
150
+
151
+ it('returns false when no draft row is found', async () => {
152
+ const { db } = makeMockDb(null)
153
+ expect(await hasDraft(db, SEED, 'entry-1')).toBe(false)
154
+ })
155
+
156
+ it('queries the correct drafts table for the seed slug', async () => {
157
+ const { db, prepareMock } = makeMockDb(null)
158
+ await hasDraft(db, SEED, 'entry-1')
159
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('content_articoli_drafts'))
160
+ })
161
+ })