@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,63 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { IActivityLogger, ActivityLogEntry, IClock, IIdGenerator } from '@beechcms/core'
3
+
4
+ const ACTIVITY_LOG_INSERT_SQL =
5
+ `INSERT INTO activity_logs
6
+ (id, user_id, user_email, user_name, action, entity_type, entity_id, entity_slug, details)
7
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
8
+
9
+ const FALLBACK_USER_EMAIL = 'unknown'
10
+
11
+ type ScheduleBackgroundTask = (task: Promise<unknown>) => void
12
+
13
+ /**
14
+ * D1-backed implementation of {@link IActivityLogger}.
15
+ *
16
+ * When `scheduleBackgroundTask` is provided (production path, wired to
17
+ * `c.executionCtx.waitUntil`), the INSERT runs after the response is sent so
18
+ * audit logging never adds latency to the user-facing request. When absent
19
+ * (tests, scripts), the INSERT runs inline and any errors are logged.
20
+ */
21
+ export class D1ActivityLogger implements IActivityLogger {
22
+ constructor(
23
+ private readonly database: D1Database,
24
+ private readonly clock: IClock,
25
+ private readonly idGenerator: IIdGenerator,
26
+ private readonly scheduleBackgroundTask?: ScheduleBackgroundTask
27
+ ) {}
28
+
29
+ log(entry: ActivityLogEntry): Promise<void> | void {
30
+ const insertPromise = this.runInsert(entry)
31
+
32
+ if (this.scheduleBackgroundTask) {
33
+ this.scheduleBackgroundTask(insertPromise)
34
+ return
35
+ }
36
+
37
+ return insertPromise
38
+ }
39
+
40
+ private async runInsert(entry: ActivityLogEntry): Promise<void> {
41
+ try {
42
+ const recordId = this.idGenerator.uuid()
43
+ const serializedDetails = entry.details ? JSON.stringify(entry.details) : null
44
+
45
+ await this.database
46
+ .prepare(ACTIVITY_LOG_INSERT_SQL)
47
+ .bind(
48
+ recordId,
49
+ entry.actor.id,
50
+ entry.actor.email || FALLBACK_USER_EMAIL,
51
+ entry.actor.name ?? null,
52
+ entry.action,
53
+ entry.entityType,
54
+ entry.entityId,
55
+ entry.entitySlug ?? null,
56
+ serializedDetails
57
+ )
58
+ .run()
59
+ } catch (error) {
60
+ console.error('D1ActivityLogger: failed to persist activity entry', error)
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { D1AnalyticsRepository } from './d1-analytics.repository'
3
+ import { FixedClock } from './fixed-clock'
4
+
5
+ const FIXED_NOW_MS = 1700000000_000
6
+ const EXPECTED_DAY_BUCKET = Math.floor(FIXED_NOW_MS / 1000 / 86400) * 86400
7
+
8
+ function makeMockDb(allResults: unknown[] = [], firstResult: unknown = null) {
9
+ const allMock = vi.fn().mockResolvedValue({ results: allResults })
10
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
11
+ const runMock = vi.fn().mockResolvedValue({ success: true })
12
+ const bindMock = vi.fn(() => ({ all: allMock, first: firstMock, run: runMock }))
13
+ const prepareMock = vi.fn(() => ({ bind: bindMock }))
14
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, allMock, firstMock, runMock }
15
+ }
16
+
17
+ describe('D1AnalyticsRepository', () => {
18
+ describe('recordRequest', () => {
19
+ it('issues an INSERT ... ON CONFLICT upsert with seed and computed day bucket', async () => {
20
+ const { db, prepareMock, bindMock, runMock } = makeMockDb()
21
+ await new D1AnalyticsRepository(db, new FixedClock(FIXED_NOW_MS)).recordRequest('posts')
22
+ const sql = prepareMock.mock.calls[0]![0] as string
23
+ expect(sql).toMatch(/INSERT INTO analytics/)
24
+ expect(sql).toMatch(/ON CONFLICT\(day_ts, metric, seed\) DO UPDATE SET value = value \+ 1/)
25
+ expect(bindMock).toHaveBeenCalledWith(EXPECTED_DAY_BUCKET, 'posts')
26
+ expect(runMock).toHaveBeenCalled()
27
+ })
28
+ })
29
+
30
+ describe('sumByMetric', () => {
31
+ it('binds requests metric, seed and since', async () => {
32
+ const { db, prepareMock, bindMock } = makeMockDb([], { total: 99 })
33
+ const total = await new D1AnalyticsRepository(db, new FixedClock(FIXED_NOW_MS)).sumByMetric('requests', 'posts', 1)
34
+ expect(total).toBe(99)
35
+ const sql = prepareMock.mock.calls[0]![0] as string
36
+ expect(sql).toMatch(/SELECT SUM\(value\) as total/)
37
+ expect(sql).toMatch(/WHERE metric = \? AND seed = \? AND day_ts >= \?/)
38
+ expect(bindMock).toHaveBeenCalledWith('requests', 'posts', 1)
39
+ })
40
+
41
+ it('returns 0 when SUM is null', async () => {
42
+ const { db } = makeMockDb([], { total: null })
43
+ const total = await new D1AnalyticsRepository(db, new FixedClock(FIXED_NOW_MS)).sumByMetric('visitors', '', 0)
44
+ expect(total).toBe(0)
45
+ })
46
+
47
+ it('returns 0 when no row matches', async () => {
48
+ const { db } = makeMockDb([], null)
49
+ const total = await new D1AnalyticsRepository(db, new FixedClock(FIXED_NOW_MS)).sumByMetric('requests', '', 0)
50
+ expect(total).toBe(0)
51
+ })
52
+ })
53
+
54
+ describe('groupByMetric', () => {
55
+ it('returns a date_label → count map filtered to metric=requests', async () => {
56
+ const { db, prepareMock, bindMock } = makeMockDb([
57
+ { date_label: '2026-01-01', daily_count: 10 },
58
+ { date_label: '2026-01-02', daily_count: 20 },
59
+ ])
60
+ const result = await new D1AnalyticsRepository(db, new FixedClock(FIXED_NOW_MS)).groupByMetric('posts', 1)
61
+ expect(result).toEqual({ '2026-01-01': 10, '2026-01-02': 20 })
62
+ const sql = prepareMock.mock.calls[0]![0] as string
63
+ expect(sql).toMatch(/strftime\('%Y-%m-%d', day_ts, 'unixepoch'\)/)
64
+ expect(sql).toMatch(/metric = 'requests'/)
65
+ expect(bindMock).toHaveBeenCalledWith('posts', 1)
66
+ })
67
+
68
+ it('skips rows with null date_label', async () => {
69
+ const { db } = makeMockDb([{ date_label: null, daily_count: 5 }])
70
+ const result = await new D1AnalyticsRepository(db, new FixedClock(FIXED_NOW_MS)).groupByMetric('', 0)
71
+ expect(result).toEqual({})
72
+ })
73
+ })
74
+ })
@@ -0,0 +1,81 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { IAnalyticsRepository, AnalyticsMetric, IClock } from '@beechcms/core'
3
+
4
+ const SECONDS_PER_DAY = 86400
5
+
6
+ /**
7
+ * D1-backed implementation of {@link IAnalyticsRepository}.
8
+ *
9
+ * The underlying schema is long-format:
10
+ * `analytics(day_ts, metric, seed, value)` with UNIQUE(day_ts, metric, seed).
11
+ * Each row stores the counter for one (day, metric, seed) tuple. The
12
+ * IAnalyticsRepository contract is metric-aware (the metric is selected via
13
+ * a switch on the validated enum), and never interpolates user values into
14
+ * SQL — the actual metric name is bound through a parameter.
15
+ */
16
+ export class D1AnalyticsRepository implements IAnalyticsRepository {
17
+ constructor(
18
+ private readonly database: D1Database,
19
+ private readonly clock: IClock,
20
+ ) {}
21
+
22
+ async recordRequest(seedSlug: string): Promise<void> {
23
+ const dayTimestamp = Math.floor(this.clock.nowSeconds() / SECONDS_PER_DAY) * SECONDS_PER_DAY
24
+ await this.database
25
+ .prepare(
26
+ `INSERT INTO analytics (day_ts, metric, seed, value)
27
+ VALUES (?, 'requests', ?, 1)
28
+ ON CONFLICT(day_ts, metric, seed) DO UPDATE SET value = value + 1`,
29
+ )
30
+ .bind(dayTimestamp, seedSlug)
31
+ .run()
32
+ }
33
+
34
+ async sumByMetric(
35
+ metric: AnalyticsMetric,
36
+ seedSlug: string,
37
+ sinceTimestamp: number,
38
+ ): Promise<number> {
39
+ const metricName = this.resolveMetricName(metric)
40
+ const row = await this.database
41
+ .prepare(
42
+ `SELECT SUM(value) as total
43
+ FROM analytics
44
+ WHERE metric = ? AND seed = ? AND day_ts >= ?`,
45
+ )
46
+ .bind(metricName, seedSlug, sinceTimestamp)
47
+ .first<{ total: number | null }>()
48
+ return row?.total ?? 0
49
+ }
50
+
51
+ async groupByMetric(
52
+ seedSlug: string,
53
+ sinceTimestamp: number,
54
+ ): Promise<Record<string, number>> {
55
+ const result = await this.database
56
+ .prepare(
57
+ `SELECT strftime('%Y-%m-%d', day_ts, 'unixepoch') as date_label,
58
+ SUM(value) as daily_count
59
+ FROM analytics
60
+ WHERE metric = 'requests' AND seed = ? AND day_ts >= ?
61
+ GROUP BY date_label
62
+ ORDER BY date_label ASC`,
63
+ )
64
+ .bind(seedSlug, sinceTimestamp)
65
+ .all<{ date_label: string | null; daily_count: number | null }>()
66
+
67
+ const grouped: Record<string, number> = {}
68
+ for (const row of result.results ?? []) {
69
+ if (row.date_label === null) continue
70
+ grouped[row.date_label] = row.daily_count ?? 0
71
+ }
72
+ return grouped
73
+ }
74
+
75
+ private resolveMetricName(metric: AnalyticsMetric): string {
76
+ switch (metric) {
77
+ case 'requests': return 'requests'
78
+ case 'visitors': return 'visitors'
79
+ }
80
+ }
81
+ }
@@ -0,0 +1,29 @@
1
+ import type { D1Database } from '@cloudflare/workers-types'
2
+ import type { IContentScanRepository, Seed } from '@beechcms/core'
3
+
4
+ export class D1ContentScanRepository implements IContentScanRepository {
5
+ constructor(private readonly db: D1Database) {}
6
+
7
+ async getReferencedMediaKeys(seeds: Seed[]): Promise<Set<string>> {
8
+ const referencedMediaKeys = new Set<string>()
9
+
10
+ for (const seed of seeds) {
11
+ const mediaFields = seed.branches.filter(branch => branch.type === 'file')
12
+ if (mediaFields.length === 0) continue
13
+
14
+ const mediaColumns = mediaFields.map(field => field.alias).join(', ')
15
+ const contentData = await this.db.prepare(
16
+ `SELECT ${mediaColumns} FROM content_${seed.slug}`
17
+ ).all<Record<string, string | null>>()
18
+
19
+ for (const contentRow of contentData.results ?? []) {
20
+ const rowContentString = Object.values(contentRow).filter(Boolean).join(' ')
21
+ for (const keyMatch of rowContentString.matchAll(/\/api\/media\/([^"'\s\\,}\]]+)/g)) {
22
+ referencedMediaKeys.add(decodeURIComponent(keyMatch[1]))
23
+ }
24
+ }
25
+ }
26
+
27
+ return referencedMediaKeys
28
+ }
29
+ }
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { D1NotificationRepository } from './d1-notification.repository'
3
+ import { FixedClock } from './fixed-clock'
4
+ import { SequentialIdGenerator } from './sequential-id-generator'
5
+
6
+ const clock = new FixedClock(1700000000_000)
7
+ const makeIdGen = () => new SequentialIdGenerator()
8
+
9
+ function makeMockDb(opts: { firstResult?: unknown; allResults?: unknown[] } = {}) {
10
+ const { firstResult = null, allResults = [] } = opts
11
+ const runMock = vi.fn().mockResolvedValue({ success: true })
12
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
13
+ const allMock = vi.fn().mockResolvedValue({ results: allResults })
14
+ const bindMock = vi.fn(() => ({ first: firstMock, all: allMock, run: runMock }))
15
+ const prepareMock = vi.fn(() => ({
16
+ bind: bindMock,
17
+ first: firstMock,
18
+ all: allMock,
19
+ run: runMock,
20
+ }))
21
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock, firstMock, allMock }
22
+ }
23
+
24
+ describe('D1NotificationRepository', () => {
25
+ describe('list', () => {
26
+ it('maps rows to NotificationRecord with isRead boolean', async () => {
27
+ const { db } = makeMockDb({
28
+ allResults: [
29
+ {
30
+ id: 'n-1',
31
+ title: 'T',
32
+ message: 'M',
33
+ type: 'success',
34
+ is_read: 0,
35
+ created_at: 100,
36
+ },
37
+ {
38
+ id: 'n-2',
39
+ title: 'T2',
40
+ message: 'M2',
41
+ type: 'info',
42
+ is_read: 1,
43
+ created_at: 99,
44
+ },
45
+ ],
46
+ })
47
+ const records = await new D1NotificationRepository(db, clock, makeIdGen()).list(10)
48
+ expect(records).toEqual([
49
+ { id: 'n-1', title: 'T', message: 'M', type: 'success', isRead: false, createdAt: 100 },
50
+ { id: 'n-2', title: 'T2', message: 'M2', type: 'info', isRead: true, createdAt: 99 },
51
+ ])
52
+ })
53
+
54
+ it('orders by created_at DESC with LIMIT bound', async () => {
55
+ const { db, prepareMock, bindMock } = makeMockDb()
56
+ await new D1NotificationRepository(db, clock, makeIdGen()).list(25)
57
+ expect(prepareMock.mock.calls[0][0]).toMatch(/ORDER BY created_at DESC/)
58
+ expect(bindMock).toHaveBeenCalledWith(25)
59
+ })
60
+ })
61
+
62
+ describe('stats', () => {
63
+ it('returns aggregate counters mapped to camelCase', async () => {
64
+ const { db } = makeMockDb({
65
+ firstResult: { total_count: 7, latest_created_at: 1234, read_count: 3 },
66
+ })
67
+ const stats = await new D1NotificationRepository(db, clock, makeIdGen()).stats()
68
+ expect(stats).toEqual({ totalCount: 7, latestCreatedAt: 1234, readCount: 3 })
69
+ })
70
+
71
+ it('returns zeros when the table is empty', async () => {
72
+ const { db } = makeMockDb({
73
+ firstResult: { total_count: 0, latest_created_at: null, read_count: null },
74
+ })
75
+ const stats = await new D1NotificationRepository(db, clock, makeIdGen()).stats()
76
+ expect(stats).toEqual({ totalCount: 0, latestCreatedAt: 0, readCount: 0 })
77
+ })
78
+ })
79
+
80
+ describe('create', () => {
81
+ it('inserts a new notification and returns the generated id', async () => {
82
+ const { db, prepareMock, bindMock } = makeMockDb()
83
+ const id = await new D1NotificationRepository(db, clock, makeIdGen()).create({
84
+ title: 'Hello',
85
+ message: 'World',
86
+ type: 'info',
87
+ })
88
+ expect(typeof id).toBe('string')
89
+ expect(id.length).toBeGreaterThan(0)
90
+ expect(prepareMock.mock.calls[0][0]).toMatch(/INSERT INTO notifications/)
91
+ expect(bindMock).toHaveBeenCalledWith(id, 'Hello', 'World', 'info')
92
+ })
93
+ })
94
+
95
+ describe('markRead / markUnread / delete / markAllRead', () => {
96
+ it('markRead binds the given id', async () => {
97
+ const { db, prepareMock, bindMock } = makeMockDb()
98
+ await new D1NotificationRepository(db, clock, makeIdGen()).markRead('n-1')
99
+ expect(prepareMock.mock.calls[0][0]).toMatch(/UPDATE notifications SET is_read = 1 WHERE id = \?/)
100
+ expect(bindMock).toHaveBeenCalledWith('n-1')
101
+ })
102
+
103
+ it('markUnread binds the given id', async () => {
104
+ const { db, prepareMock, bindMock } = makeMockDb()
105
+ await new D1NotificationRepository(db, clock, makeIdGen()).markUnread('n-2')
106
+ expect(prepareMock.mock.calls[0][0]).toMatch(/UPDATE notifications SET is_read = 0 WHERE id = \?/)
107
+ expect(bindMock).toHaveBeenCalledWith('n-2')
108
+ })
109
+
110
+ it('delete binds the given id', async () => {
111
+ const { db, prepareMock, bindMock } = makeMockDb()
112
+ await new D1NotificationRepository(db, clock, makeIdGen()).delete('n-3')
113
+ expect(prepareMock.mock.calls[0][0]).toMatch(/DELETE FROM notifications WHERE id = \?/)
114
+ expect(bindMock).toHaveBeenCalledWith('n-3')
115
+ })
116
+
117
+ it('markAllRead runs without bindings', async () => {
118
+ const { db, prepareMock, runMock } = makeMockDb()
119
+ await new D1NotificationRepository(db, clock, makeIdGen()).markAllRead()
120
+ expect(prepareMock.mock.calls[0][0]).toMatch(/^UPDATE notifications SET is_read = 1$/)
121
+ expect(runMock).toHaveBeenCalled()
122
+ })
123
+ })
124
+ })
@@ -0,0 +1,114 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type {
3
+ INotificationRepository,
4
+ NotificationRecord,
5
+ NotificationStats,
6
+ NotificationType,
7
+ IClock,
8
+ IIdGenerator,
9
+ } from '@beechcms/core'
10
+
11
+ interface NotificationRow {
12
+ id: string
13
+ title: string
14
+ message: string
15
+ type: string
16
+ is_read: number
17
+ created_at: number
18
+ }
19
+
20
+ interface NotificationStatsRow {
21
+ total_count: number | null
22
+ latest_created_at: number | null
23
+ read_count: number | null
24
+ }
25
+
26
+ /**
27
+ * D1-backed implementation of {@link INotificationRepository}.
28
+ */
29
+ export class D1NotificationRepository implements INotificationRepository {
30
+ constructor(
31
+ private readonly database: D1Database,
32
+ private readonly clock: IClock,
33
+ private readonly idGenerator: IIdGenerator,
34
+ ) {}
35
+
36
+ async list(limit: number): Promise<NotificationRecord[]> {
37
+ const queryResult = await this.database
38
+ .prepare(
39
+ `SELECT id, title, message, type, is_read, created_at
40
+ FROM notifications
41
+ ORDER BY created_at DESC
42
+ LIMIT ?`
43
+ )
44
+ .bind(limit)
45
+ .all<NotificationRow>()
46
+
47
+ return (queryResult.results ?? []).map(mapRowToRecord)
48
+ }
49
+
50
+ async stats(): Promise<NotificationStats> {
51
+ const statsRow = await this.database
52
+ .prepare(
53
+ `SELECT COUNT(*) as total_count,
54
+ MAX(created_at) as latest_created_at,
55
+ SUM(is_read) as read_count
56
+ FROM notifications`
57
+ )
58
+ .first<NotificationStatsRow>()
59
+
60
+ return {
61
+ totalCount: statsRow?.total_count ?? 0,
62
+ latestCreatedAt: statsRow?.latest_created_at ?? 0,
63
+ readCount: statsRow?.read_count ?? 0,
64
+ }
65
+ }
66
+
67
+ async create(record: Omit<NotificationRecord, 'id' | 'createdAt' | 'isRead'>): Promise<string> {
68
+ const generatedId = this.idGenerator.uuid()
69
+ await this.database
70
+ .prepare(
71
+ `INSERT INTO notifications (id, title, message, type)
72
+ VALUES (?, ?, ?, ?)`
73
+ )
74
+ .bind(generatedId, record.title, record.message, record.type)
75
+ .run()
76
+ return generatedId
77
+ }
78
+
79
+ async markRead(notificationId: string): Promise<void> {
80
+ await this.database
81
+ .prepare('UPDATE notifications SET is_read = 1 WHERE id = ?')
82
+ .bind(notificationId)
83
+ .run()
84
+ }
85
+
86
+ async markUnread(notificationId: string): Promise<void> {
87
+ await this.database
88
+ .prepare('UPDATE notifications SET is_read = 0 WHERE id = ?')
89
+ .bind(notificationId)
90
+ .run()
91
+ }
92
+
93
+ async markAllRead(): Promise<void> {
94
+ await this.database.prepare('UPDATE notifications SET is_read = 1').run()
95
+ }
96
+
97
+ async delete(notificationId: string): Promise<void> {
98
+ await this.database
99
+ .prepare('DELETE FROM notifications WHERE id = ?')
100
+ .bind(notificationId)
101
+ .run()
102
+ }
103
+ }
104
+
105
+ function mapRowToRecord(row: NotificationRow): NotificationRecord {
106
+ return {
107
+ id: row.id,
108
+ title: row.title,
109
+ message: row.message,
110
+ type: row.type as NotificationType,
111
+ isRead: row.is_read === 1,
112
+ createdAt: row.created_at,
113
+ }
114
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { D1PasswordResetTokenRepository } from './d1-password-reset-token.repository'
3
+
4
+ const NOW = Math.floor(Date.now() / 1000)
5
+ const fixedIdGen = { uuid: () => 'prt-1' }
6
+
7
+ function makeMockDb(opts: { firstResult?: unknown; runChanges?: number } = {}) {
8
+ const { firstResult = null, runChanges = 1 } = opts
9
+ const runMock = vi.fn().mockResolvedValue({ success: true, meta: { changes: runChanges } })
10
+ const firstMock = vi.fn().mockResolvedValue(firstResult)
11
+ const bindMock = vi.fn(() => ({ first: firstMock, run: runMock }))
12
+ const prepareMock = vi.fn(() => ({ bind: bindMock }))
13
+ return { db: { prepare: prepareMock } as any, prepareMock, bindMock, runMock }
14
+ }
15
+
16
+ describe('D1PasswordResetTokenRepository', () => {
17
+ describe('create', () => {
18
+ it('calls INSERT INTO password_reset_tokens', async () => {
19
+ const { db, prepareMock } = makeMockDb()
20
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).create({
21
+ userId: 'u1', tokenHash: 'h', expiresAt: NOW + 1800,
22
+ })
23
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('INSERT INTO password_reset_tokens'))
24
+ })
25
+
26
+ it('binds id, userId, tokenHash, and expiresAt in the correct order', async () => {
27
+ const { db, bindMock } = makeMockDb()
28
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).create({
29
+ userId: 'u1', tokenHash: 'h', expiresAt: NOW + 1800,
30
+ })
31
+ expect(bindMock).toHaveBeenCalledWith('prt-1', 'u1', 'h', NOW + 1800)
32
+ })
33
+ })
34
+
35
+ describe('findValidByHashWithEmail', () => {
36
+ it('returns a ValidatedResetToken with the userId and email from the JOIN', async () => {
37
+ const row = { id: 'prt-1', user_id: 'u1', email: 'user@test.com' }
38
+ const { db } = makeMockDb({ firstResult: row })
39
+ const result = await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('h', NOW)
40
+ expect(result).toEqual({ id: 'prt-1', userId: 'u1', email: 'user@test.com' })
41
+ })
42
+
43
+ it('returns null when the token is not found, expired, or already used', async () => {
44
+ const { db } = makeMockDb({ firstResult: null })
45
+ expect(await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('bad-hash', NOW)).toBeNull()
46
+ })
47
+
48
+ it('passes tokenHash and nowTimestamp as bound values', async () => {
49
+ const { db, bindMock } = makeMockDb()
50
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).findValidByHashWithEmail('my-hash', NOW)
51
+ expect(bindMock).toHaveBeenCalledWith('my-hash', NOW)
52
+ })
53
+ })
54
+
55
+ describe('markUsed', () => {
56
+ it('calls UPDATE password_reset_tokens SET used_at', async () => {
57
+ const { db, prepareMock } = makeMockDb()
58
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).markUsed('prt-1', NOW)
59
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('used_at'))
60
+ })
61
+
62
+ it('binds nowTimestamp and tokenId in the correct order', async () => {
63
+ const { db, bindMock } = makeMockDb()
64
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).markUsed('prt-1', NOW)
65
+ expect(bindMock).toHaveBeenCalledWith(NOW, 'prt-1')
66
+ })
67
+ })
68
+
69
+ describe('invalidatePending', () => {
70
+ it('calls UPDATE password_reset_tokens and includes the userId in the bound values', async () => {
71
+ const { db, prepareMock, bindMock } = makeMockDb()
72
+ await new D1PasswordResetTokenRepository(db, fixedIdGen).invalidatePending('u1', NOW)
73
+ expect(prepareMock).toHaveBeenCalledWith(expect.stringContaining('UPDATE password_reset_tokens'))
74
+ expect((bindMock.mock.calls[0] as unknown[]).includes('u1')).toBe(true)
75
+ })
76
+ })
77
+ })
@@ -0,0 +1,52 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ import type { IPasswordResetTokenRepository, NewPasswordResetToken, ValidatedResetToken, IIdGenerator } from '@beechcms/core'
3
+
4
+ type ValidatedResetTokenRow = {
5
+ id: string
6
+ user_id: string
7
+ email: string
8
+ }
9
+
10
+ export class D1PasswordResetTokenRepository implements IPasswordResetTokenRepository {
11
+ constructor(
12
+ private readonly db: D1Database,
13
+ private readonly idGenerator: IIdGenerator,
14
+ ) {}
15
+
16
+ async invalidatePending(userId: string, nowTimestamp: number): Promise<void> {
17
+ await this.db
18
+ .prepare('UPDATE password_reset_tokens SET used_at = ? WHERE user_id = ? AND used_at IS NULL')
19
+ .bind(nowTimestamp, userId)
20
+ .run()
21
+ }
22
+
23
+ async create(record: NewPasswordResetToken): Promise<void> {
24
+ const generatedId = this.idGenerator.uuid()
25
+ await this.db
26
+ .prepare('INSERT INTO password_reset_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)')
27
+ .bind(generatedId, record.userId, record.tokenHash, record.expiresAt)
28
+ .run()
29
+ }
30
+
31
+ async findValidByHashWithEmail(tokenHash: string, nowTimestamp: number): Promise<ValidatedResetToken | null> {
32
+ const row = await this.db
33
+ .prepare(
34
+ `SELECT prt.id, prt.user_id, u.email
35
+ FROM password_reset_tokens prt
36
+ JOIN users u ON u.id = prt.user_id
37
+ WHERE prt.token_hash = ? AND prt.expires_at > ? AND prt.used_at IS NULL`
38
+ )
39
+ .bind(tokenHash, nowTimestamp)
40
+ .first<ValidatedResetTokenRow>()
41
+
42
+ if (!row) return null
43
+ return { id: row.id, userId: row.user_id, email: row.email }
44
+ }
45
+
46
+ async markUsed(tokenId: string, nowTimestamp: number): Promise<void> {
47
+ await this.db
48
+ .prepare('UPDATE password_reset_tokens SET used_at = ? WHERE id = ?')
49
+ .bind(nowTimestamp, tokenId)
50
+ .run()
51
+ }
52
+ }