@beechcms/api 0.4.0-preview.11 → 0.4.0-preview.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/assets/dashboard/BeechLogo.svg +18 -18
- package/assets/dashboard/BeechLogoLIght.svg +48 -48
- package/assets/dashboard/assets/index-CewtCjom.css +1 -0
- package/assets/dashboard/assets/index-FQ6JhvRH.js +554 -0
- package/assets/dashboard/beechLogoDark.svg +48 -48
- package/assets/dashboard/index.html +18 -18
- package/assets/dashboard/sol.svg +3 -3
- package/assets/dashboard/undraw_enter_nwx3.svg +36 -36
- package/migrations/0000_v040_base.sql +213 -213
- package/package.json +2 -2
- package/src/auth/constants.ts +10 -10
- package/src/auth/login.ts +91 -91
- package/src/auth/refresh.ts +127 -127
- package/src/factory.ts +347 -325
- package/src/features/content/constants.ts +10 -10
- package/src/features/content/handlers/create.ts +153 -163
- package/src/features/content/handlers/delete.ts +76 -85
- package/src/features/content/handlers/facets.ts +45 -45
- package/src/features/content/handlers/get.ts +116 -116
- package/src/features/content/handlers/list.ts +88 -88
- package/src/features/content/handlers/update.ts +207 -216
- package/src/features/content/index.ts +20 -20
- package/src/features/draft/draft.handler.ts +272 -272
- package/src/features/draft/index.ts +1 -1
- package/src/features/email/email.provider.ts +38 -38
- package/src/features/email/email.service.ts +80 -80
- package/src/features/email/email.types.ts +98 -98
- package/src/features/email/index.ts +28 -28
- package/src/features/email/providers/resend.ts +63 -63
- package/src/features/email/templates/password-changed.ts +59 -59
- package/src/features/email/templates/password-reset.ts +64 -64
- package/src/features/email/templates/shell.ts +92 -93
- package/src/features/notifications/index.ts +1 -1
- package/src/features/notifications/notifications.handler.ts +130 -88
- package/src/features/password-reset/index.ts +15 -15
- package/src/features/password-reset/request.ts +106 -88
- package/src/features/password-reset/reset.ts +128 -110
- package/src/features/rotate-field/index.ts +1 -1
- package/src/features/rotate-field/rotate-field.handler.ts +132 -82
- package/src/features/rotate-field/rotate-field.schema.ts +13 -9
- package/src/features/schema/schema.handler.ts +16 -16
- package/src/features/settings/settings.handler.ts +414 -267
- package/src/features/setup/index.ts +96 -59
- package/src/features/stats/index.ts +1 -1
- package/src/features/stats/stats.handler.ts +458 -395
- package/src/index.ts +24 -24
- package/src/media-utils.ts +78 -78
- package/src/middleware/repository.middleware.ts +24 -18
- package/src/middleware/storage.middleware.ts +21 -0
- package/src/middleware.ts +67 -67
- package/src/public/access-policy.ts +23 -23
- package/src/public/api-key-middleware.ts +53 -53
- package/src/public/index.ts +12 -12
- package/src/public/problem-details.ts +48 -42
- package/src/public/public-add.ts +166 -166
- package/src/public/public-edit.ts +158 -158
- package/src/public/public-errors.ts +15 -15
- package/src/public/public-read.ts +216 -216
- package/src/public/public-routes.ts +84 -31
- package/src/public/query-builder.ts +152 -152
- package/src/public/rate-limit-middleware.ts +42 -42
- package/src/public/response-builder.ts +26 -26
- package/src/public/sanitize.ts +65 -65
- package/src/public/slug-utils.ts +14 -14
- package/src/search-utils.ts +192 -192
- package/src/search.ts +72 -72
- package/src/shared/activity-logger.ts +79 -79
- package/src/shared/apply-policies.ts +63 -63
- package/src/shared/base.repository.d1.ts +28 -28
- package/src/shared/content-utils.ts +82 -108
- package/src/shared/content.repository.d1.ts +382 -382
- package/src/shared/fts-sync.ts +4 -4
- package/src/shared/idempotency.repository.d1.ts +56 -45
- package/src/shared/media.repository.d1.ts +64 -0
- package/src/shared/notification-service.ts +56 -56
- package/src/shared/query-utils.ts +137 -137
- package/src/shared/storage/factory.ts +40 -0
- package/src/shared/storage/r2-binding-bucket.ts +81 -0
- package/src/shared/storage/s3-bucket.ts +163 -0
- package/src/shared/storage-utils.ts +36 -36
- package/src/shared/system-stats.repository.d1.ts +44 -0
- package/src/types.ts +46 -41
- package/src/upload.ts +179 -331
- package/src/widget.ts +349 -349
- package/assets/dashboard/assets/index-9Ch2xWJr.js +0 -554
- package/assets/dashboard/assets/index-ye3325L9.css +0 -1
package/src/shared/fts-sync.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// v0.4.0: FTS sync handled automatically by SQL triggers (generateFtsTriggers).
|
|
2
|
-
// syncFts/deleteFts eliminated. This file kept as empty module to avoid import errors
|
|
3
|
-
// during the Phase 4 → Phase 6 transition.
|
|
4
|
-
export {}
|
|
1
|
+
// v0.4.0: FTS sync handled automatically by SQL triggers (generateFtsTriggers).
|
|
2
|
+
// syncFts/deleteFts eliminated. This file kept as empty module to avoid import errors
|
|
3
|
+
// during the Phase 4 → Phase 6 transition.
|
|
4
|
+
export {}
|
|
@@ -1,45 +1,56 @@
|
|
|
1
|
-
import { IdempotencyRepository, IdempotencyRecord } from '@beechcms/core'
|
|
2
|
-
import { BaseD1Repository } from './base.repository.d1.js'
|
|
3
|
-
|
|
4
|
-
export class D1IdempotencyRepository extends BaseD1Repository implements IdempotencyRepository {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
1
|
+
import { IdempotencyRepository, IdempotencyRecord } from '@beechcms/core'
|
|
2
|
+
import { BaseD1Repository } from './base.repository.d1.js'
|
|
3
|
+
|
|
4
|
+
export class D1IdempotencyRepository extends BaseD1Repository implements IdempotencyRepository {
|
|
5
|
+
/**
|
|
6
|
+
* Looks up an idempotency record by its key.
|
|
7
|
+
*/
|
|
8
|
+
async lookup(idempotencyKey: string): Promise<IdempotencyRecord | null> {
|
|
9
|
+
const idempotencyRecordResult = await this.database.prepare(
|
|
10
|
+
`SELECT idempotency_key, request_fingerprint, response_status, response_body, expires_at
|
|
11
|
+
FROM public_idempotency_keys WHERE idempotency_key = ? LIMIT 1`
|
|
12
|
+
).bind(idempotencyKey).first<any>()
|
|
13
|
+
|
|
14
|
+
if (!idempotencyRecordResult) {
|
|
15
|
+
return null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
key: idempotencyRecordResult.idempotency_key,
|
|
20
|
+
fingerprint: idempotencyRecordResult.request_fingerprint,
|
|
21
|
+
responseStatus: idempotencyRecordResult.response_status,
|
|
22
|
+
responseBody: idempotencyRecordResult.response_body,
|
|
23
|
+
expiresAt: idempotencyRecordResult.expires_at
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Stores or updates an idempotency record.
|
|
29
|
+
*/
|
|
30
|
+
async store(idempotencyData: IdempotencyRecord): Promise<void> {
|
|
31
|
+
await this.database.prepare(
|
|
32
|
+
`INSERT INTO public_idempotency_keys (idempotency_key, request_fingerprint, response_status, response_body, created_at, expires_at)
|
|
33
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
34
|
+
ON CONFLICT(idempotency_key) DO UPDATE SET
|
|
35
|
+
request_fingerprint = excluded.request_fingerprint,
|
|
36
|
+
response_status = excluded.response_status,
|
|
37
|
+
response_body = excluded.response_body,
|
|
38
|
+
created_at = excluded.created_at,
|
|
39
|
+
expires_at = excluded.expires_at`
|
|
40
|
+
).bind(
|
|
41
|
+
idempotencyData.key,
|
|
42
|
+
idempotencyData.fingerprint,
|
|
43
|
+
idempotencyData.responseStatus,
|
|
44
|
+
idempotencyData.responseBody,
|
|
45
|
+
Math.floor(Date.now() / 1000),
|
|
46
|
+
idempotencyData.expiresAt
|
|
47
|
+
).run()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Cleans up expired idempotency records.
|
|
52
|
+
*/
|
|
53
|
+
async cleanup(expirationTimestamp: number): Promise<void> {
|
|
54
|
+
await this.database.prepare(`DELETE FROM public_idempotency_keys WHERE expires_at < ?`).bind(expirationTimestamp).run()
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { MediaRepository, MediaObject } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Implementation of MediaRepository using Cloudflare D1.
|
|
5
|
+
* Provides methods to track media uploads, retrieve media metadata, and manage the media library state in the database.
|
|
6
|
+
*/
|
|
7
|
+
export class D1MediaRepository implements MediaRepository {
|
|
8
|
+
constructor(private database: D1Database) {}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Registers a new media upload in the database.
|
|
12
|
+
*/
|
|
13
|
+
async trackUpload(mediaObject: MediaObject): Promise<void> {
|
|
14
|
+
await this.database.prepare(
|
|
15
|
+
'INSERT INTO media_objects (key, filename, mime_type, size_bytes, uploaded_by) VALUES (?, ?, ?, ?, ?)'
|
|
16
|
+
).bind(
|
|
17
|
+
mediaObject.key,
|
|
18
|
+
mediaObject.filename,
|
|
19
|
+
mediaObject.mime_type,
|
|
20
|
+
mediaObject.size_bytes,
|
|
21
|
+
mediaObject.uploaded_by
|
|
22
|
+
).run()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Retrieves a media object's metadata by its unique key.
|
|
27
|
+
*/
|
|
28
|
+
async getByKey(mediaKey: string): Promise<MediaObject | null> {
|
|
29
|
+
return await this.database.prepare(
|
|
30
|
+
'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects WHERE key = ?'
|
|
31
|
+
).bind(mediaKey).first<MediaObject>()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Removes the metadata tracking for a specific media key.
|
|
36
|
+
*/
|
|
37
|
+
async untrack(mediaKey: string): Promise<void> {
|
|
38
|
+
await this.database.prepare('DELETE FROM media_objects WHERE key = ?').bind(mediaKey).run()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Lists media objects with pagination support.
|
|
43
|
+
*/
|
|
44
|
+
async list(paginationOptions: { limit: number; offset: number }): Promise<{ items: MediaObject[]; total: number }> {
|
|
45
|
+
const { results: mediaItems } = await this.database.prepare(
|
|
46
|
+
'SELECT key, filename, mime_type, size_bytes, uploaded_by, created_at FROM media_objects ORDER BY created_at DESC LIMIT ? OFFSET ?'
|
|
47
|
+
).bind(paginationOptions.limit, paginationOptions.offset).all<MediaObject>()
|
|
48
|
+
|
|
49
|
+
const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
items: mediaItems ?? [],
|
|
53
|
+
total: totalCountRecord?.total ?? 0
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns the total number of media objects in the database.
|
|
59
|
+
*/
|
|
60
|
+
async count(): Promise<number> {
|
|
61
|
+
const totalCountRecord = await this.database.prepare('SELECT COUNT(*) as total FROM media_objects').first<{ total: number }>()
|
|
62
|
+
return totalCountRecord?.total ?? 0
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -1,56 +1,56 @@
|
|
|
1
|
-
import { Context } from 'hono'
|
|
2
|
-
|
|
3
|
-
export interface NotificationParams {
|
|
4
|
-
title: string
|
|
5
|
-
message: string
|
|
6
|
-
type?: 'info' | 'success' | 'warning' | 'error'
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Crea una notifica persistente nel database D1.
|
|
11
|
-
* Usata principalmente per segnalare azioni provenienti dalle Public API.
|
|
12
|
-
*/
|
|
13
|
-
export async function createNotification(
|
|
14
|
-
c: Context<any>,
|
|
15
|
-
params: NotificationParams
|
|
16
|
-
): Promise<void> {
|
|
17
|
-
const db = c.env.DB as D1Database
|
|
18
|
-
if (!db) return
|
|
19
|
-
|
|
20
|
-
const { title, message, type = 'info' } = params
|
|
21
|
-
const id = crypto.randomUUID()
|
|
22
|
-
|
|
23
|
-
// Usiamo waitUntil per non bloccare la richiesta dell'utente esterno
|
|
24
|
-
let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
|
|
25
|
-
try {
|
|
26
|
-
executionCtx = c.executionCtx
|
|
27
|
-
} catch {
|
|
28
|
-
// In ambiente di test Hono lancia se non presente
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
if (executionCtx) {
|
|
32
|
-
executionCtx.waitUntil((async () => {
|
|
33
|
-
try {
|
|
34
|
-
await db.prepare(
|
|
35
|
-
`INSERT INTO notifications (id, title, message, type)
|
|
36
|
-
VALUES (?, ?, ?, ?)`
|
|
37
|
-
).bind(id, title, message, type).run()
|
|
38
|
-
} catch (err) {
|
|
39
|
-
console.error('Failed to create notification:', err)
|
|
40
|
-
}
|
|
41
|
-
})())
|
|
42
|
-
} else {
|
|
43
|
-
// Fallback sync per test o ambienti senza executionCtx (se vogliamo che le notifiche siano create)
|
|
44
|
-
// Oppure semplicemente ignoriamo. In questo caso, per i test, meglio tentare di crearle
|
|
45
|
-
// ma dato che è un'azione opzionale "background", in test possiamo saltarla o farla sync.
|
|
46
|
-
// Facciamola sync se non c'è executionCtx per garantire che i test che verificano le notifiche (se ce ne sono) passino.
|
|
47
|
-
try {
|
|
48
|
-
await db.prepare(
|
|
49
|
-
`INSERT INTO notifications (id, title, message, type)
|
|
50
|
-
VALUES (?, ?, ?, ?)`
|
|
51
|
-
).bind(id, title, message, type).run()
|
|
52
|
-
} catch (err) {
|
|
53
|
-
console.error('Failed to create notification (sync fallback):', err)
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
1
|
+
import { Context } from 'hono'
|
|
2
|
+
|
|
3
|
+
export interface NotificationParams {
|
|
4
|
+
title: string
|
|
5
|
+
message: string
|
|
6
|
+
type?: 'info' | 'success' | 'warning' | 'error'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Crea una notifica persistente nel database D1.
|
|
11
|
+
* Usata principalmente per segnalare azioni provenienti dalle Public API.
|
|
12
|
+
*/
|
|
13
|
+
export async function createNotification(
|
|
14
|
+
c: Context<any>,
|
|
15
|
+
params: NotificationParams
|
|
16
|
+
): Promise<void> {
|
|
17
|
+
const db = c.env.DB as D1Database
|
|
18
|
+
if (!db) return
|
|
19
|
+
|
|
20
|
+
const { title, message, type = 'info' } = params
|
|
21
|
+
const id = crypto.randomUUID()
|
|
22
|
+
|
|
23
|
+
// Usiamo waitUntil per non bloccare la richiesta dell'utente esterno
|
|
24
|
+
let executionCtx: { waitUntil: (p: Promise<any>) => void } | undefined
|
|
25
|
+
try {
|
|
26
|
+
executionCtx = c.executionCtx
|
|
27
|
+
} catch {
|
|
28
|
+
// In ambiente di test Hono lancia se non presente
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (executionCtx) {
|
|
32
|
+
executionCtx.waitUntil((async () => {
|
|
33
|
+
try {
|
|
34
|
+
await db.prepare(
|
|
35
|
+
`INSERT INTO notifications (id, title, message, type)
|
|
36
|
+
VALUES (?, ?, ?, ?)`
|
|
37
|
+
).bind(id, title, message, type).run()
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('Failed to create notification:', err)
|
|
40
|
+
}
|
|
41
|
+
})())
|
|
42
|
+
} else {
|
|
43
|
+
// Fallback sync per test o ambienti senza executionCtx (se vogliamo che le notifiche siano create)
|
|
44
|
+
// Oppure semplicemente ignoriamo. In questo caso, per i test, meglio tentare di crearle
|
|
45
|
+
// ma dato che è un'azione opzionale "background", in test possiamo saltarla o farla sync.
|
|
46
|
+
// Facciamola sync se non c'è executionCtx per garantire che i test che verificano le notifiche (se ce ne sono) passino.
|
|
47
|
+
try {
|
|
48
|
+
await db.prepare(
|
|
49
|
+
`INSERT INTO notifications (id, title, message, type)
|
|
50
|
+
VALUES (?, ?, ?, ?)`
|
|
51
|
+
).bind(id, title, message, type).run()
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error('Failed to create notification (sync fallback):', err)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -1,137 +1,137 @@
|
|
|
1
|
-
import type { FilterGroup, FilterType } from '@beechcms/core'
|
|
2
|
-
|
|
3
|
-
/** Entry parsata per le risposte API — contratto immutabile (C7). */
|
|
4
|
-
export interface ContentEntry {
|
|
5
|
-
id: string
|
|
6
|
-
schema_slug: string
|
|
7
|
-
slug: string | null
|
|
8
|
-
status: string
|
|
9
|
-
data: Record<string, unknown>
|
|
10
|
-
hasPendingDraft: boolean
|
|
11
|
-
created_at: number | null
|
|
12
|
-
updated_at: number | null
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export type QueryFilterType = FilterType
|
|
16
|
-
|
|
17
|
-
export type QueryFilterOperator =
|
|
18
|
-
| 'eq'
|
|
19
|
-
| 'gt'
|
|
20
|
-
| 'gte'
|
|
21
|
-
| 'lt'
|
|
22
|
-
| 'lte'
|
|
23
|
-
| 'contains'
|
|
24
|
-
| 'is_empty'
|
|
25
|
-
| 'is_not_empty'
|
|
26
|
-
|
|
27
|
-
export interface QueryFilterCondition {
|
|
28
|
-
id?: string
|
|
29
|
-
op: QueryFilterOperator
|
|
30
|
-
value: string | number | boolean | null
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Formato inviato dalla dashboard (usa columnId). */
|
|
34
|
-
export interface QueryFilterGroup {
|
|
35
|
-
columnId: string
|
|
36
|
-
label?: string
|
|
37
|
-
type: QueryFilterType
|
|
38
|
-
conditions: QueryFilterCondition[]
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
|
|
42
|
-
'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
|
|
43
|
-
])
|
|
44
|
-
|
|
45
|
-
function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
|
|
46
|
-
return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export function cleanStr(val: unknown): string | null {
|
|
50
|
-
return (typeof val === 'string' && val.trim()) || null
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function safeParseJson(data: unknown): Record<string, unknown> {
|
|
54
|
-
const cleaned = cleanStr(data)
|
|
55
|
-
if (!cleaned) return {}
|
|
56
|
-
try {
|
|
57
|
-
const parsed = JSON.parse(cleaned)
|
|
58
|
-
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
59
|
-
? (parsed as Record<string, unknown>)
|
|
60
|
-
: {}
|
|
61
|
-
} catch {
|
|
62
|
-
return {}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
export function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|
67
|
-
if (!value) return fallback
|
|
68
|
-
const parsed = Number.parseInt(value, 10)
|
|
69
|
-
if (Number.isNaN(parsed) || parsed < 1) return fallback
|
|
70
|
-
return parsed
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function parseCondition(cond: unknown): QueryFilterCondition | null {
|
|
74
|
-
if (!cond || typeof cond !== 'object') return null
|
|
75
|
-
const candidate = cond as Record<string, unknown>
|
|
76
|
-
if (!isQueryFilterOperator(candidate.op)) return null
|
|
77
|
-
const rawValue = candidate.value
|
|
78
|
-
let parsedValue: QueryFilterCondition['value'] = null
|
|
79
|
-
if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
|
|
80
|
-
parsedValue = rawValue
|
|
81
|
-
}
|
|
82
|
-
return {
|
|
83
|
-
id: typeof candidate.id === 'string' ? candidate.id : undefined,
|
|
84
|
-
op: candidate.op,
|
|
85
|
-
value: parsedValue,
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function parseFilterGroup(group: unknown): QueryFilterGroup | null {
|
|
90
|
-
if (!group || typeof group !== 'object') return null
|
|
91
|
-
const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
|
|
92
|
-
if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
|
|
93
|
-
return null
|
|
94
|
-
}
|
|
95
|
-
const validConditions: QueryFilterCondition[] = []
|
|
96
|
-
for (const cond of rawConds) {
|
|
97
|
-
const parsedCond = parseCondition(cond)
|
|
98
|
-
if (parsedCond) validConditions.push(parsedCond)
|
|
99
|
-
}
|
|
100
|
-
if (validConditions.length === 0) return null
|
|
101
|
-
return {
|
|
102
|
-
columnId,
|
|
103
|
-
label: typeof label === 'string' ? label : undefined,
|
|
104
|
-
type,
|
|
105
|
-
conditions: validConditions,
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
|
|
110
|
-
export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
|
|
111
|
-
if (!raw) return []
|
|
112
|
-
let parsed: unknown
|
|
113
|
-
try {
|
|
114
|
-
parsed = JSON.parse(raw)
|
|
115
|
-
} catch {
|
|
116
|
-
return []
|
|
117
|
-
}
|
|
118
|
-
if (!parsed || typeof parsed !== 'object') return []
|
|
119
|
-
const result: QueryFilterGroup[] = []
|
|
120
|
-
for (const group of Object.values(parsed as Record<string, unknown>)) {
|
|
121
|
-
const parsedGroup = parseFilterGroup(group)
|
|
122
|
-
if (parsedGroup) result.push(parsedGroup)
|
|
123
|
-
}
|
|
124
|
-
return result
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
/**
|
|
128
|
-
* Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
|
|
129
|
-
* compatibile con buildSelectQuery del Botanical Engine.
|
|
130
|
-
*/
|
|
131
|
-
export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
|
|
132
|
-
return groups.map((g) => ({
|
|
133
|
-
column: g.columnId,
|
|
134
|
-
type: g.type,
|
|
135
|
-
conditions: g.conditions,
|
|
136
|
-
}))
|
|
137
|
-
}
|
|
1
|
+
import type { FilterGroup, FilterType } from '@beechcms/core'
|
|
2
|
+
|
|
3
|
+
/** Entry parsata per le risposte API — contratto immutabile (C7). */
|
|
4
|
+
export interface ContentEntry {
|
|
5
|
+
id: string
|
|
6
|
+
schema_slug: string
|
|
7
|
+
slug: string | null
|
|
8
|
+
status: string
|
|
9
|
+
data: Record<string, unknown>
|
|
10
|
+
hasPendingDraft: boolean
|
|
11
|
+
created_at: number | null
|
|
12
|
+
updated_at: number | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type QueryFilterType = FilterType
|
|
16
|
+
|
|
17
|
+
export type QueryFilterOperator =
|
|
18
|
+
| 'eq'
|
|
19
|
+
| 'gt'
|
|
20
|
+
| 'gte'
|
|
21
|
+
| 'lt'
|
|
22
|
+
| 'lte'
|
|
23
|
+
| 'contains'
|
|
24
|
+
| 'is_empty'
|
|
25
|
+
| 'is_not_empty'
|
|
26
|
+
|
|
27
|
+
export interface QueryFilterCondition {
|
|
28
|
+
id?: string
|
|
29
|
+
op: QueryFilterOperator
|
|
30
|
+
value: string | number | boolean | null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Formato inviato dalla dashboard (usa columnId). */
|
|
34
|
+
export interface QueryFilterGroup {
|
|
35
|
+
columnId: string
|
|
36
|
+
label?: string
|
|
37
|
+
type: QueryFilterType
|
|
38
|
+
conditions: QueryFilterCondition[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const QUERY_FILTER_OPERATOR_SET = new Set<QueryFilterOperator>([
|
|
42
|
+
'eq', 'gt', 'gte', 'lt', 'lte', 'contains', 'is_empty', 'is_not_empty',
|
|
43
|
+
])
|
|
44
|
+
|
|
45
|
+
function isQueryFilterOperator(value: unknown): value is QueryFilterOperator {
|
|
46
|
+
return typeof value === 'string' && QUERY_FILTER_OPERATOR_SET.has(value as QueryFilterOperator)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function cleanStr(val: unknown): string | null {
|
|
50
|
+
return (typeof val === 'string' && val.trim()) || null
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function safeParseJson(data: unknown): Record<string, unknown> {
|
|
54
|
+
const cleaned = cleanStr(data)
|
|
55
|
+
if (!cleaned) return {}
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(cleaned)
|
|
58
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
59
|
+
? (parsed as Record<string, unknown>)
|
|
60
|
+
: {}
|
|
61
|
+
} catch {
|
|
62
|
+
return {}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function parsePositiveInt(value: string | undefined, fallback: number): number {
|
|
67
|
+
if (!value) return fallback
|
|
68
|
+
const parsed = Number.parseInt(value, 10)
|
|
69
|
+
if (Number.isNaN(parsed) || parsed < 1) return fallback
|
|
70
|
+
return parsed
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function parseCondition(cond: unknown): QueryFilterCondition | null {
|
|
74
|
+
if (!cond || typeof cond !== 'object') return null
|
|
75
|
+
const candidate = cond as Record<string, unknown>
|
|
76
|
+
if (!isQueryFilterOperator(candidate.op)) return null
|
|
77
|
+
const rawValue = candidate.value
|
|
78
|
+
let parsedValue: QueryFilterCondition['value'] = null
|
|
79
|
+
if (typeof rawValue === 'string' || typeof rawValue === 'number' || typeof rawValue === 'boolean') {
|
|
80
|
+
parsedValue = rawValue
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
id: typeof candidate.id === 'string' ? candidate.id : undefined,
|
|
84
|
+
op: candidate.op,
|
|
85
|
+
value: parsedValue,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseFilterGroup(group: unknown): QueryFilterGroup | null {
|
|
90
|
+
if (!group || typeof group !== 'object') return null
|
|
91
|
+
const { columnId, type, label, conditions: rawConds } = group as Partial<QueryFilterGroup>
|
|
92
|
+
if (typeof columnId !== 'string' || typeof type !== 'string' || !Array.isArray(rawConds)) {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
const validConditions: QueryFilterCondition[] = []
|
|
96
|
+
for (const cond of rawConds) {
|
|
97
|
+
const parsedCond = parseCondition(cond)
|
|
98
|
+
if (parsedCond) validConditions.push(parsedCond)
|
|
99
|
+
}
|
|
100
|
+
if (validConditions.length === 0) return null
|
|
101
|
+
return {
|
|
102
|
+
columnId,
|
|
103
|
+
label: typeof label === 'string' ? label : undefined,
|
|
104
|
+
type,
|
|
105
|
+
conditions: validConditions,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Parse sicuro dei filtri da query-string (formato dashboard con columnId). */
|
|
110
|
+
export function parseQueryFilters(raw: string | undefined): QueryFilterGroup[] {
|
|
111
|
+
if (!raw) return []
|
|
112
|
+
let parsed: unknown
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(raw)
|
|
115
|
+
} catch {
|
|
116
|
+
return []
|
|
117
|
+
}
|
|
118
|
+
if (!parsed || typeof parsed !== 'object') return []
|
|
119
|
+
const result: QueryFilterGroup[] = []
|
|
120
|
+
for (const group of Object.values(parsed as Record<string, unknown>)) {
|
|
121
|
+
const parsedGroup = parseFilterGroup(group)
|
|
122
|
+
if (parsedGroup) result.push(parsedGroup)
|
|
123
|
+
}
|
|
124
|
+
return result
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Converte QueryFilterGroup[] (formato dashboard, columnId) in FilterGroup[]
|
|
129
|
+
* compatibile con buildSelectQuery del Botanical Engine.
|
|
130
|
+
*/
|
|
131
|
+
export function toEngineFilters(groups: QueryFilterGroup[]): FilterGroup[] {
|
|
132
|
+
return groups.map((g) => ({
|
|
133
|
+
column: g.columnId,
|
|
134
|
+
type: g.type,
|
|
135
|
+
conditions: g.conditions,
|
|
136
|
+
}))
|
|
137
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { BeechBucket, PutBucketOptions, GetBucketResult } from '@beechcms/core'
|
|
2
|
+
import { Env } from '../../types'
|
|
3
|
+
import { R2BindingBucket } from './r2-binding-bucket'
|
|
4
|
+
import { S3Bucket } from './s3-bucket'
|
|
5
|
+
|
|
6
|
+
class NullBucket implements BeechBucket {
|
|
7
|
+
private fail(): never {
|
|
8
|
+
throw new Error('No storage provider configured. Set MEDIA_BUCKET or R2 credentials.')
|
|
9
|
+
}
|
|
10
|
+
put(_key: string, _body: ArrayBuffer | Uint8Array | ReadableStream, _options?: PutBucketOptions): Promise<void> { return this.fail() }
|
|
11
|
+
get(_key: string): Promise<GetBucketResult | null> { return this.fail() }
|
|
12
|
+
delete(_key: string): Promise<void> { return this.fail() }
|
|
13
|
+
head(_key: string): Promise<{ size: number; contentType?: string; metadata?: Record<string, string> } | null> { return this.fail() }
|
|
14
|
+
getUrl(_key: string): string { return this.fail() }
|
|
15
|
+
getTotalSize(): Promise<number> { return this.fail() }
|
|
16
|
+
list(_options?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ objects: Array<{ key: string; size: number }>; cursor?: string }> { return this.fail() }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Creates the appropriate BeechBucket based on available bindings/credentials.
|
|
21
|
+
* Uses capability detection: R2 binding takes precedence over S3-compatible credentials.
|
|
22
|
+
*/
|
|
23
|
+
export function createBucketProvider(env: Env, baseUrl: string): BeechBucket {
|
|
24
|
+
if (env.MEDIA_BUCKET) {
|
|
25
|
+
return new R2BindingBucket(env.MEDIA_BUCKET, baseUrl)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY && env.R2_ENDPOINT && env.R2_BUCKET_NAME) {
|
|
29
|
+
return new S3Bucket({
|
|
30
|
+
endpoint: env.R2_ENDPOINT,
|
|
31
|
+
accessKeyId: env.R2_ACCESS_KEY_ID,
|
|
32
|
+
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
|
33
|
+
bucketName: env.R2_BUCKET_NAME,
|
|
34
|
+
baseUrl,
|
|
35
|
+
cdnUrl: env.MEDIA_CDN_URL?.trim().replace(/\/$/, '') || undefined,
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return new NullBucket()
|
|
40
|
+
}
|