@ekrist1/vulse 0.1.6-alpha.3 → 0.1.7-alpha.4
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/dist/cli/migrate.d.ts.map +1 -1
- package/dist/cli/migrate.js +3 -4
- package/dist/cli/setup.d.ts.map +1 -1
- package/dist/cli/setup.js +11 -10
- package/dist/core/blueprints/compile.d.ts +1 -1
- package/dist/core/blueprints/compile.d.ts.map +1 -1
- package/dist/core/blueprints/compile.js +3 -3
- package/dist/core/blueprints/mutations.js +2 -2
- package/dist/core/forms/rate-limit.d.ts +1 -1
- package/dist/core/forms/rate-limit.d.ts.map +1 -1
- package/dist/core/forms/rate-limit.js +3 -3
- package/dist/core/forms/unique.d.ts +1 -1
- package/dist/core/forms/unique.d.ts.map +1 -1
- package/dist/core/forms/unique.js +4 -4
- package/dist/core/globals/compile.d.ts +1 -1
- package/dist/core/globals/compile.d.ts.map +1 -1
- package/dist/core/globals/compile.js +2 -2
- package/dist/core/globals/definition.d.ts +1 -1
- package/dist/core/globals/definition.d.ts.map +1 -1
- package/dist/core/globals/definition.js +3 -3
- package/dist/core/repos/globals.js +3 -3
- package/dist/core/sha256.d.ts +3 -0
- package/dist/core/sha256.d.ts.map +1 -0
- package/dist/core/sha256.js +9 -0
- package/dist/integration/index.js +1 -1
- package/dist/integration/install-hook.d.ts.map +1 -1
- package/dist/integration/install-hook.js +6 -4
- package/dist/integration/wrangler-config.d.ts +12 -0
- package/dist/integration/wrangler-config.d.ts.map +1 -0
- package/dist/integration/wrangler-config.js +97 -0
- package/dist/integration/wrangler-patch.d.ts +1 -0
- package/dist/integration/wrangler-patch.d.ts.map +1 -1
- package/dist/integration/wrangler-patch.js +2 -1
- package/dist/server/routes/form-submit.js +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +11 -3
- package/src/admin/assets/logo-mark.svg +5 -0
- package/src/admin/client/active-locale.ts +17 -0
- package/src/admin/client/api.ts +21 -0
- package/src/admin/client/form-from-zod.ts +7 -0
- package/src/admin/client/live-preview-enabled.ts +5 -0
- package/src/admin/components/AdminShell.astro +45 -0
- package/src/admin/components/AuthSettings.vue +60 -0
- package/src/admin/components/BlockEditor.vue +53 -0
- package/src/admin/components/BlueprintEditor.vue +1783 -0
- package/src/admin/components/CollectionKindIcon.vue +26 -0
- package/src/admin/components/CollectionTree.vue +220 -0
- package/src/admin/components/EntryEditorWithPreview.vue +130 -0
- package/src/admin/components/EntryForm.vue +411 -0
- package/src/admin/components/EntryList.vue +121 -0
- package/src/admin/components/EntryStatusBadge.vue +24 -0
- package/src/admin/components/FormEditor.vue +233 -0
- package/src/admin/components/FormList.vue +54 -0
- package/src/admin/components/GlobalSetEditor.vue +272 -0
- package/src/admin/components/GlobalSetList.vue +55 -0
- package/src/admin/components/LivePreviewPanel.vue +171 -0
- package/src/admin/components/LoginForm.vue +53 -0
- package/src/admin/components/MediaLibrary.vue +106 -0
- package/src/admin/components/MediaPicker.vue +49 -0
- package/src/admin/components/RevisionDiff.vue +11 -0
- package/src/admin/components/RevisionList.vue +134 -0
- package/src/admin/components/SeoFields.vue +113 -0
- package/src/admin/components/SetEditor.vue +137 -0
- package/src/admin/components/SetList.vue +32 -0
- package/src/admin/components/SettingsForm.vue +189 -0
- package/src/admin/components/SideNav.vue +152 -0
- package/src/admin/components/SubmissionDetail.vue +45 -0
- package/src/admin/components/SubmissionList.vue +89 -0
- package/src/admin/components/ToastHost.vue +33 -0
- package/src/admin/components/TreeRow.vue +163 -0
- package/src/admin/components/UserEditor.vue +186 -0
- package/src/admin/components/UserList.vue +46 -0
- package/src/admin/components/blocks/BlockItem.vue +32 -0
- package/src/admin/components/blocks/BlockToolbar.vue +12 -0
- package/src/admin/components/blocks/edit/CodeEdit.vue +18 -0
- package/src/admin/components/blocks/edit/EmbedEdit.vue +14 -0
- package/src/admin/components/blocks/edit/HeadingEdit.vue +19 -0
- package/src/admin/components/blocks/edit/ImageEdit.vue +40 -0
- package/src/admin/components/blocks/edit/ListEdit.vue +36 -0
- package/src/admin/components/blocks/edit/ParagraphEdit.vue +14 -0
- package/src/admin/components/blocks/edit/QuoteEdit.vue +18 -0
- package/src/admin/components/fields/BlocksField.vue +123 -0
- package/src/admin/components/fields/BlocksSetsPicker.vue +59 -0
- package/src/admin/components/fields/BoolField.vue +10 -0
- package/src/admin/components/fields/DateField.vue +22 -0
- package/src/admin/components/fields/EntriesField.vue +153 -0
- package/src/admin/components/fields/EntryField.vue +138 -0
- package/src/admin/components/fields/EnumField.vue +81 -0
- package/src/admin/components/fields/FieldRenderer.vue +87 -0
- package/src/admin/components/fields/GridField.vue +173 -0
- package/src/admin/components/fields/LinkField.vue +219 -0
- package/src/admin/components/fields/MediaField.vue +69 -0
- package/src/admin/components/fields/NumberField.vue +12 -0
- package/src/admin/components/fields/ObjectField.vue +18 -0
- package/src/admin/components/fields/RefField.vue +170 -0
- package/src/admin/components/fields/RepeaterField.vue +27 -0
- package/src/admin/components/fields/ReplicatorField.vue +121 -0
- package/src/admin/components/fields/TextField.vue +11 -0
- package/src/admin/components/fields/TextareaField.vue +11 -0
- package/src/admin/components/fields/VulseAccordionGroupNodeView.vue +82 -0
- package/src/admin/components/fields/VulseAccordionNodeView.vue +128 -0
- package/src/admin/components/fields/VulseCalloutNodeView.vue +81 -0
- package/src/admin/components/fields/VulseIframeNodeView.vue +112 -0
- package/src/admin/components/fields/VulseSetNodeView.vue +68 -0
- package/src/admin/components/fields/VulseVideoNodeView.vue +104 -0
- package/src/admin/components/fields/blocks-editor-extensions.ts +26 -0
- package/src/admin/components/fields/emoji-extension.ts +54 -0
- package/src/admin/components/fields/link-extension.ts +48 -0
- package/src/admin/components/fields/set-node-utils.ts +115 -0
- package/src/admin/components/fields/url-utils.ts +85 -0
- package/src/admin/components/fields/vulse-accordion-extension.ts +64 -0
- package/src/admin/components/fields/vulse-accordion-group-extension.ts +49 -0
- package/src/admin/components/fields/vulse-callout-extension.ts +53 -0
- package/src/admin/components/fields/vulse-iframe-extension.ts +96 -0
- package/src/admin/components/fields/vulse-set-extension.ts +66 -0
- package/src/admin/components/fields/vulse-video-extension.ts +65 -0
- package/src/admin/composables/toast.ts +35 -0
- package/src/admin/composables/useEntrySearch.ts +112 -0
- package/src/admin/composables/useSets.ts +31 -0
- package/src/admin/pages/collections/[name]/[id]/revisions.astro +27 -0
- package/src/admin/pages/collections/[name]/[id].astro +90 -0
- package/src/admin/pages/collections/[name]/index.astro +31 -0
- package/src/admin/pages/collections/[name]/new.astro +38 -0
- package/src/admin/pages/forms/[handle]/submissions/[id].astro +9 -0
- package/src/admin/pages/forms/[handle]/submissions/index.astro +9 -0
- package/src/admin/pages/forms/[handle].astro +9 -0
- package/src/admin/pages/forms/index.astro +7 -0
- package/src/admin/pages/forms/new.astro +7 -0
- package/src/admin/pages/index.astro +36 -0
- package/src/admin/pages/login.astro +14 -0
- package/src/admin/pages/media.astro +8 -0
- package/src/admin/pages/schema/[handle].astro +10 -0
- package/src/admin/pages/schema/new.astro +9 -0
- package/src/admin/pages/settings/auth.astro +9 -0
- package/src/admin/pages/settings/globals/[handle].astro +9 -0
- package/src/admin/pages/settings/globals/index.astro +7 -0
- package/src/admin/pages/settings/globals/new.astro +7 -0
- package/src/admin/pages/settings/index.astro +8 -0
- package/src/admin/pages/settings/sets/[handle].astro +9 -0
- package/src/admin/pages/settings/sets/index.astro +7 -0
- package/src/admin/pages/settings/sets/new.astro +7 -0
- package/src/admin/pages/users/[id].astro +10 -0
- package/src/admin/pages/users/index.astro +8 -0
- package/src/admin/styles/admin.css +166 -0
- package/src/core/access.ts +9 -0
- package/src/core/blocks/schema.ts +66 -0
- package/src/core/blueprints/code-to-definition.ts +156 -0
- package/src/core/blueprints/compile.ts +176 -0
- package/src/core/blueprints/define.ts +12 -0
- package/src/core/blueprints/definition.ts +185 -0
- package/src/core/blueprints/load.ts +144 -0
- package/src/core/blueprints/mutations.ts +236 -0
- package/src/core/blueprints/preview-path.ts +33 -0
- package/src/core/blueprints/reflect-fields.ts +305 -0
- package/src/core/blueprints/registry.ts +14 -0
- package/src/core/blueprints/seed.ts +20 -0
- package/src/core/blueprints/select-helpers.ts +30 -0
- package/src/core/blueprints/seo.ts +180 -0
- package/src/core/blueprints/types.ts +59 -0
- package/src/core/blueprints/zod-helpers.ts +86 -0
- package/src/core/db.ts +11 -0
- package/src/core/errors.ts +34 -0
- package/src/core/forms/compile.ts +84 -0
- package/src/core/forms/definition.ts +102 -0
- package/src/core/forms/rate-limit.ts +52 -0
- package/src/core/forms/unique.ts +38 -0
- package/src/core/globals/compile.ts +35 -0
- package/src/core/globals/definition.ts +27 -0
- package/src/core/locales.ts +45 -0
- package/src/core/migrations.ts +48 -0
- package/src/core/parse-content.ts +85 -0
- package/src/core/plugins/definition.ts +150 -0
- package/src/core/preview-content.ts +21 -0
- package/src/core/repos/entries.ts +504 -0
- package/src/core/repos/forms.ts +270 -0
- package/src/core/repos/globals.ts +179 -0
- package/src/core/repos/media.ts +106 -0
- package/src/core/repos/preview-sessions.ts +108 -0
- package/src/core/repos/revisions.ts +60 -0
- package/src/core/repos/settings.ts +23 -0
- package/src/core/schema.ts +244 -0
- package/src/core/sets/compile.ts +12 -0
- package/src/core/sets/definition.ts +10 -0
- package/src/core/sets/service.ts +82 -0
- package/src/core/sets/validate-tree.ts +57 -0
- package/src/core/sha256.ts +10 -0
- package/src/core/slug.ts +30 -0
- package/src/scaffold/collection-write.ts +83 -0
- package/src/scaffold/collection.ts +277 -0
- package/src/server/assets/live-preview-bridge.content.ts +2 -0
- package/src/server/assets/live-preview-bridge.js +535 -0
- package/src/server/better-auth.ts +82 -0
- package/src/server/cf-images.ts +34 -0
- package/src/server/cron.ts +37 -0
- package/src/server/email.ts +17 -0
- package/src/server/endpoints/api-auth.ts +10 -0
- package/src/server/endpoints/api-vulse-blueprints.ts +23 -0
- package/src/server/endpoints/api-vulse-entries-locales.ts +12 -0
- package/src/server/endpoints/api-vulse-entries-move.ts +7 -0
- package/src/server/endpoints/api-vulse-entries-publish.ts +7 -0
- package/src/server/endpoints/api-vulse-entries-tree.ts +7 -0
- package/src/server/endpoints/api-vulse-entries.ts +23 -0
- package/src/server/endpoints/api-vulse-form-handle.ts +30 -0
- package/src/server/endpoints/api-vulse-form-public.ts +7 -0
- package/src/server/endpoints/api-vulse-form-submit.ts +7 -0
- package/src/server/endpoints/api-vulse-form-upload.ts +7 -0
- package/src/server/endpoints/api-vulse-forms.ts +12 -0
- package/src/server/endpoints/api-vulse-globals-handle.ts +20 -0
- package/src/server/endpoints/api-vulse-globals-public-handle.ts +7 -0
- package/src/server/endpoints/api-vulse-globals-public.ts +7 -0
- package/src/server/endpoints/api-vulse-globals-value.ts +8 -0
- package/src/server/endpoints/api-vulse-globals.ts +12 -0
- package/src/server/endpoints/api-vulse-media-file.ts +7 -0
- package/src/server/endpoints/api-vulse-media-id.ts +12 -0
- package/src/server/endpoints/api-vulse-media.ts +12 -0
- package/src/server/endpoints/api-vulse-preview-bridge.ts +11 -0
- package/src/server/endpoints/api-vulse-preview-sessions-id.ts +10 -0
- package/src/server/endpoints/api-vulse-preview-sessions.ts +7 -0
- package/src/server/endpoints/api-vulse-preview-start.ts +7 -0
- package/src/server/endpoints/api-vulse-preview-stop.ts +7 -0
- package/src/server/endpoints/api-vulse-revisions-restore.ts +7 -0
- package/src/server/endpoints/api-vulse-revisions.ts +7 -0
- package/src/server/endpoints/api-vulse-search.ts +7 -0
- package/src/server/endpoints/api-vulse-sets.ts +23 -0
- package/src/server/endpoints/api-vulse-settings.ts +12 -0
- package/src/server/endpoints/api-vulse-users-id.ts +12 -0
- package/src/server/endpoints/api-vulse-users-reset-password.ts +7 -0
- package/src/server/endpoints/api-vulse-users-role.ts +9 -0
- package/src/server/endpoints/api-vulse-users.ts +7 -0
- package/src/server/endpoints/with-runtime.ts +11 -0
- package/src/server/env.ts +23 -0
- package/src/server/envelope.ts +21 -0
- package/src/server/forms/email.ts +11 -0
- package/src/server/forms/process-submission.ts +95 -0
- package/src/server/forms/queue.ts +25 -0
- package/src/server/forms/templates.ts +24 -0
- package/src/server/forms/webhook.ts +19 -0
- package/src/server/handler.ts +66 -0
- package/src/server/image-probe.ts +35 -0
- package/src/server/loader.ts +54 -0
- package/src/server/plugins.ts +214 -0
- package/src/server/preview.ts +25 -0
- package/src/server/r2.ts +13 -0
- package/src/server/routes/blueprints.ts +62 -0
- package/src/server/routes/entries.ts +255 -0
- package/src/server/routes/form-submit.ts +168 -0
- package/src/server/routes/form-upload.ts +100 -0
- package/src/server/routes/forms.ts +88 -0
- package/src/server/routes/globals-public.ts +30 -0
- package/src/server/routes/globals.ts +93 -0
- package/src/server/routes/media.ts +145 -0
- package/src/server/routes/preview-sessions.ts +76 -0
- package/src/server/routes/preview.ts +36 -0
- package/src/server/routes/revisions.ts +29 -0
- package/src/server/routes/search.ts +31 -0
- package/src/server/routes/sets.ts +40 -0
- package/src/server/routes/settings.ts +24 -0
- package/src/server/routes/users.ts +127 -0
- package/src/server/runtime.ts +99 -0
- package/src/server/sdk/collections.ts +98 -0
- package/src/server/sdk/index.ts +25 -0
- package/src/server/sdk/media.ts +11 -0
- package/src/server/sdk/search.ts +90 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
import type { VulseDb } from '../../core/db.js'
|
|
3
|
+
import type { Auth } from '../better-auth.js'
|
|
4
|
+
import type { BlueprintRegistry } from '../../core/blueprints/registry.js'
|
|
5
|
+
import { DEFAULT_LOCALE, EntriesRepo } from '../../core/repos/entries.js'
|
|
6
|
+
import { AccessDeniedError, NotFoundError, ValidationError } from '../../core/errors.js'
|
|
7
|
+
import { evaluate } from '../../core/access.js'
|
|
8
|
+
import { parseContent } from '../../core/parse-content.js'
|
|
9
|
+
import { defineHandler } from '../handler.js'
|
|
10
|
+
import { isValidLocaleCode, readLocalesConfig } from '../../core/locales.js'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The URL slug is owned by the entry locale row. If a user schema also declares a
|
|
14
|
+
* `slug` field (common for templates), it is hidden from the form; we mirror
|
|
15
|
+
* the canonical slug into content here so schemas that require it still parse.
|
|
16
|
+
*/
|
|
17
|
+
function withCanonicalSlug(content: unknown, slug: string | undefined): unknown {
|
|
18
|
+
if (slug === undefined) return content
|
|
19
|
+
if (content === null || typeof content !== 'object' || Array.isArray(content)) return content
|
|
20
|
+
return { ...(content as Record<string, unknown>), slug }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const paramsByCollection = z.object({ collection: z.string() })
|
|
24
|
+
const paramsById = z.object({ collection: z.string(), id: z.string() })
|
|
25
|
+
|
|
26
|
+
async function resolveLocaleParam(db: VulseDb, raw: string | null | undefined): Promise<string> {
|
|
27
|
+
const cfg = await readLocalesConfig(db)
|
|
28
|
+
if (!raw || raw === DEFAULT_LOCALE) return cfg.defaultLocale
|
|
29
|
+
if (!isValidLocaleCode(raw)) throw new ValidationError(`Invalid locale code: ${raw}`)
|
|
30
|
+
if (!cfg.locales.includes(raw)) {
|
|
31
|
+
throw new ValidationError(`Locale '${raw}' is not enabled for this site.`, {
|
|
32
|
+
field: 'locale',
|
|
33
|
+
supported: cfg.locales,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
return raw
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function entriesRoutes(db: VulseDb, auth: Auth, reg: BlueprintRegistry) {
|
|
40
|
+
const entries = new EntriesRepo(db)
|
|
41
|
+
|
|
42
|
+
function blueprintFor(name: string) {
|
|
43
|
+
const bp = reg.get(name)
|
|
44
|
+
if (!bp) throw new NotFoundError(`Unknown collection: ${name}`)
|
|
45
|
+
return bp
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
list: defineHandler(auth, { params: paramsByCollection }, async ({ params, url, auth: authCtx }) => {
|
|
50
|
+
const bp = blueprintFor(params.collection)
|
|
51
|
+
const locale = await resolveLocaleParam(db, url.searchParams.get('locale'))
|
|
52
|
+
if (!(await evaluate(bp, 'read', { user: authCtx.user }))) {
|
|
53
|
+
return await entries.list({ collection: params.collection, locale, status: 'published' })
|
|
54
|
+
}
|
|
55
|
+
const parentRaw = url.searchParams.get('parentId')
|
|
56
|
+
const parentId = parentRaw === 'root' || parentRaw === '' ? null : parentRaw ?? undefined
|
|
57
|
+
return await entries.list({
|
|
58
|
+
collection: params.collection,
|
|
59
|
+
locale,
|
|
60
|
+
...(parentId !== undefined ? { parentId } : {}),
|
|
61
|
+
})
|
|
62
|
+
}),
|
|
63
|
+
|
|
64
|
+
tree: defineHandler(auth, {
|
|
65
|
+
params: paramsByCollection,
|
|
66
|
+
requireRole: ['admin', 'editor'],
|
|
67
|
+
}, async ({ params, url }) => {
|
|
68
|
+
const bp = blueprintFor(params.collection)
|
|
69
|
+
if (!bp.tree) throw new ValidationError('Collection does not support tree structure')
|
|
70
|
+
const locale = await resolveLocaleParam(db, url.searchParams.get('locale'))
|
|
71
|
+
return await entries.tree(params.collection, locale)
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
findById: defineHandler(auth, { params: paramsById }, async ({ params, url, auth: authCtx }) => {
|
|
75
|
+
const bp = blueprintFor(params.collection)
|
|
76
|
+
const locale = await resolveLocaleParam(db, url.searchParams.get('locale'))
|
|
77
|
+
const row = await entries.findById(params.id, locale)
|
|
78
|
+
if (!row) throw new NotFoundError(`Entry ${params.id} (${locale}) not found`)
|
|
79
|
+
const allowed = await evaluate(bp, 'read', {
|
|
80
|
+
user: authCtx.user,
|
|
81
|
+
entry: { id: row.id, status: row.status, createdBy: row.createdBy, content: row.content },
|
|
82
|
+
})
|
|
83
|
+
if (!allowed) throw new NotFoundError(`Entry ${params.id} not found`)
|
|
84
|
+
return row
|
|
85
|
+
}),
|
|
86
|
+
|
|
87
|
+
listLocales: defineHandler(auth, {
|
|
88
|
+
params: paramsById,
|
|
89
|
+
requireRole: ['admin', 'editor'],
|
|
90
|
+
}, async ({ params }) => {
|
|
91
|
+
blueprintFor(params.collection)
|
|
92
|
+
return await entries.listLocales(params.id)
|
|
93
|
+
}),
|
|
94
|
+
|
|
95
|
+
create: defineHandler(auth, {
|
|
96
|
+
params: paramsByCollection,
|
|
97
|
+
body: z.object({
|
|
98
|
+
slug: z.string(),
|
|
99
|
+
content: z.unknown(),
|
|
100
|
+
status: z.enum(['draft', 'published']).optional(),
|
|
101
|
+
parentId: z.string().nullable().optional(),
|
|
102
|
+
locale: z.string().optional(),
|
|
103
|
+
}),
|
|
104
|
+
}, async ({ params, body, auth: authCtx }) => {
|
|
105
|
+
const bp = blueprintFor(params.collection)
|
|
106
|
+
const allowed = await evaluate(bp, 'create', { user: authCtx.user })
|
|
107
|
+
if (!allowed) throw new AccessDeniedError('Cannot create')
|
|
108
|
+
if (!authCtx.user) throw new AccessDeniedError('Authentication required')
|
|
109
|
+
if (body.parentId && !bp.tree) throw new ValidationError('Collection does not support nesting')
|
|
110
|
+
const locale = await resolveLocaleParam(db, body.locale)
|
|
111
|
+
const validated = parseContent(bp.schema, withCanonicalSlug(body.content, body.slug))
|
|
112
|
+
return await entries.create({
|
|
113
|
+
collection: params.collection,
|
|
114
|
+
slug: body.slug,
|
|
115
|
+
content: validated,
|
|
116
|
+
locale,
|
|
117
|
+
...(body.status !== undefined ? { status: body.status } : {}),
|
|
118
|
+
...(body.parentId !== undefined ? { parentId: body.parentId } : {}),
|
|
119
|
+
draftsEnabled: bp.drafts === true,
|
|
120
|
+
createdBy: authCtx.user.id,
|
|
121
|
+
})
|
|
122
|
+
}),
|
|
123
|
+
|
|
124
|
+
createLocale: defineHandler(auth, {
|
|
125
|
+
params: paramsById,
|
|
126
|
+
body: z.object({
|
|
127
|
+
locale: z.string(),
|
|
128
|
+
slug: z.string(),
|
|
129
|
+
content: z.unknown(),
|
|
130
|
+
status: z.enum(['draft', 'published']).optional(),
|
|
131
|
+
}),
|
|
132
|
+
}, async ({ params, body, auth: authCtx }) => {
|
|
133
|
+
const bp = blueprintFor(params.collection)
|
|
134
|
+
const allowed = await evaluate(bp, 'create', { user: authCtx.user })
|
|
135
|
+
if (!allowed) throw new AccessDeniedError('Cannot create')
|
|
136
|
+
if (!authCtx.user) throw new AccessDeniedError('Authentication required')
|
|
137
|
+
const locale = await resolveLocaleParam(db, body.locale)
|
|
138
|
+
const validated = parseContent(bp.schema, withCanonicalSlug(body.content, body.slug))
|
|
139
|
+
return await entries.createLocale(params.id, {
|
|
140
|
+
locale,
|
|
141
|
+
slug: body.slug,
|
|
142
|
+
content: validated,
|
|
143
|
+
updatedBy: authCtx.user.id,
|
|
144
|
+
...(body.status !== undefined ? { status: body.status } : {}),
|
|
145
|
+
draftsEnabled: bp.drafts === true,
|
|
146
|
+
})
|
|
147
|
+
}),
|
|
148
|
+
|
|
149
|
+
update: defineHandler(auth, {
|
|
150
|
+
params: paramsById,
|
|
151
|
+
body: z.object({
|
|
152
|
+
slug: z.string().optional(),
|
|
153
|
+
content: z.unknown().optional(),
|
|
154
|
+
status: z.enum(['draft', 'published']).optional(),
|
|
155
|
+
changeSummary: z.string().optional(),
|
|
156
|
+
publish: z.boolean().optional(),
|
|
157
|
+
locale: z.string().optional(),
|
|
158
|
+
}),
|
|
159
|
+
}, async ({ params, body, auth: authCtx }) => {
|
|
160
|
+
const bp = blueprintFor(params.collection)
|
|
161
|
+
const locale = await resolveLocaleParam(db, body.locale)
|
|
162
|
+
const row = await entries.findById(params.id, locale)
|
|
163
|
+
if (!row) throw new NotFoundError(`Entry ${params.id} (${locale}) not found`)
|
|
164
|
+
const allowed = await evaluate(bp, 'update', {
|
|
165
|
+
user: authCtx.user,
|
|
166
|
+
entry: { id: row.id, status: row.status, createdBy: row.createdBy, content: row.content },
|
|
167
|
+
})
|
|
168
|
+
if (!allowed) throw new AccessDeniedError('Cannot update')
|
|
169
|
+
if (!authCtx.user) throw new AccessDeniedError('Authentication required')
|
|
170
|
+
const nextSlug = body.slug ?? row.slug
|
|
171
|
+
const validated = body.content !== undefined
|
|
172
|
+
? parseContent(bp.schema, withCanonicalSlug(body.content, nextSlug))
|
|
173
|
+
: undefined
|
|
174
|
+
return await entries.updateWithRevision(params.id, {
|
|
175
|
+
locale,
|
|
176
|
+
...(body.slug !== undefined ? { slug: body.slug } : {}),
|
|
177
|
+
...(validated !== undefined ? { content: validated } : {}),
|
|
178
|
+
...(body.status !== undefined ? { status: body.status } : {}),
|
|
179
|
+
...(body.publish !== undefined ? { publish: body.publish } : {}),
|
|
180
|
+
draftsEnabled: bp.drafts === true,
|
|
181
|
+
updatedBy: authCtx.user.id,
|
|
182
|
+
...(body.changeSummary !== undefined ? { changeSummary: body.changeSummary } : {}),
|
|
183
|
+
})
|
|
184
|
+
}),
|
|
185
|
+
|
|
186
|
+
move: defineHandler(auth, {
|
|
187
|
+
params: paramsById,
|
|
188
|
+
body: z.object({
|
|
189
|
+
parentId: z.string().nullable(),
|
|
190
|
+
sortOrder: z.number().int().positive().optional(),
|
|
191
|
+
}),
|
|
192
|
+
}, async ({ params, body, auth: authCtx }) => {
|
|
193
|
+
const bp = blueprintFor(params.collection)
|
|
194
|
+
if (!bp.tree) throw new ValidationError('Collection does not support tree structure')
|
|
195
|
+
const shell = await entries.findShellById(params.id)
|
|
196
|
+
if (!shell || shell.collection !== params.collection) throw new NotFoundError(`Entry ${params.id} not found`)
|
|
197
|
+
const allowed = await evaluate(bp, 'update', {
|
|
198
|
+
user: authCtx.user,
|
|
199
|
+
entry: { id: shell.id, status: 'draft', createdBy: shell.createdBy, content: {} },
|
|
200
|
+
})
|
|
201
|
+
if (!allowed) throw new AccessDeniedError('Cannot move')
|
|
202
|
+
return await entries.move(params.collection, params.id, {
|
|
203
|
+
parentId: body.parentId,
|
|
204
|
+
...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}),
|
|
205
|
+
})
|
|
206
|
+
}),
|
|
207
|
+
|
|
208
|
+
publish: defineHandler(auth, { params: paramsById }, async ({ params, url, auth: authCtx }) => {
|
|
209
|
+
const bp = blueprintFor(params.collection)
|
|
210
|
+
if (!bp.drafts) throw new ValidationError('Drafts not enabled for this collection')
|
|
211
|
+
const locale = await resolveLocaleParam(db, url.searchParams.get('locale'))
|
|
212
|
+
const row = await entries.findById(params.id, locale)
|
|
213
|
+
if (!row) throw new NotFoundError(`Entry ${params.id} (${locale}) not found`)
|
|
214
|
+
const allowed = await evaluate(bp, 'update', {
|
|
215
|
+
user: authCtx.user,
|
|
216
|
+
entry: { id: row.id, status: row.status, createdBy: row.createdBy, content: row.content },
|
|
217
|
+
})
|
|
218
|
+
if (!allowed) throw new AccessDeniedError('Cannot publish')
|
|
219
|
+
if (!authCtx.user) throw new AccessDeniedError('Authentication required')
|
|
220
|
+
const content = row.draftContent ?? row.content
|
|
221
|
+
const validated = parseContent(bp.schema, content)
|
|
222
|
+
return await entries.updateWithRevision(params.id, {
|
|
223
|
+
locale,
|
|
224
|
+
content: validated,
|
|
225
|
+
publish: true,
|
|
226
|
+
draftsEnabled: true,
|
|
227
|
+
updatedBy: authCtx.user.id,
|
|
228
|
+
})
|
|
229
|
+
}),
|
|
230
|
+
|
|
231
|
+
delete: defineHandler(auth, { params: paramsById }, async ({ params, url, auth: authCtx }) => {
|
|
232
|
+
const bp = blueprintFor(params.collection)
|
|
233
|
+
const localeParam = url.searchParams.get('locale')
|
|
234
|
+
const shell = await entries.findShellById(params.id)
|
|
235
|
+
if (!shell || shell.collection !== params.collection) throw new NotFoundError(`Entry ${params.id} not found`)
|
|
236
|
+
const allowed = await evaluate(bp, 'delete', {
|
|
237
|
+
user: authCtx.user,
|
|
238
|
+
entry: { id: shell.id, status: 'draft', createdBy: shell.createdBy, content: {} },
|
|
239
|
+
})
|
|
240
|
+
if (!allowed) throw new AccessDeniedError('Cannot delete')
|
|
241
|
+
if (localeParam) {
|
|
242
|
+
const locale = await resolveLocaleParam(db, localeParam)
|
|
243
|
+
const summaries = await entries.listLocales(params.id)
|
|
244
|
+
if (summaries.length <= 1) {
|
|
245
|
+
await entries.delete(params.id)
|
|
246
|
+
return { deleted: true }
|
|
247
|
+
}
|
|
248
|
+
await entries.deleteLocale(params.id, locale)
|
|
249
|
+
return { deleted: true, locale }
|
|
250
|
+
}
|
|
251
|
+
await entries.delete(params.id)
|
|
252
|
+
return { deleted: true }
|
|
253
|
+
}),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import type { VulseDb } from '../../core/db.js'
|
|
2
|
+
import { FormsRepo, SubmissionsRepo, FormUploadDraftsRepo } from '../../core/repos/forms.js'
|
|
3
|
+
import { compileForm } from '../../core/forms/compile.js'
|
|
4
|
+
import { checkRateLimit, hashIp } from '../../core/forms/rate-limit.js'
|
|
5
|
+
import { insertUniqueValues } from '../../core/forms/unique.js'
|
|
6
|
+
import { NotFoundError, ValidationError } from '../../core/errors.js'
|
|
7
|
+
import { fail, ok } from '../envelope.js'
|
|
8
|
+
import { enqueueFormProcess } from '../forms/queue.js'
|
|
9
|
+
import { runFormAfterSubmitHooks, runFormBeforeSubmitHooks } from '../plugins.js'
|
|
10
|
+
|
|
11
|
+
export interface FormSubmitRouteOptions {
|
|
12
|
+
queue?: Queue
|
|
13
|
+
env?: Record<string, unknown>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formSubmitRoutes(db: VulseDb, options: FormSubmitRouteOptions = {}) {
|
|
17
|
+
const forms = new FormsRepo(db)
|
|
18
|
+
const submissions = new SubmissionsRepo(db)
|
|
19
|
+
const drafts = new FormUploadDraftsRepo(db)
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
public: async (request: Request, rawParams: Record<string, string>): Promise<Response> => {
|
|
23
|
+
try {
|
|
24
|
+
const handle = rawParams.handle
|
|
25
|
+
if (!handle) throw new ValidationError('handle required')
|
|
26
|
+
const form = await forms.findByHandle(handle)
|
|
27
|
+
if (!form || !form.enabled) throw new NotFoundError('form not found')
|
|
28
|
+
|
|
29
|
+
const def = form.definition
|
|
30
|
+
const honeypot = def.settings.honeypotField ?? '_hp'
|
|
31
|
+
const publicFields = def.fields
|
|
32
|
+
.filter((f) => f.ui.kind !== 'honeypot')
|
|
33
|
+
.map((f) => ({
|
|
34
|
+
name: f.name,
|
|
35
|
+
label: f.label,
|
|
36
|
+
ui: f.ui,
|
|
37
|
+
optional: f.optional,
|
|
38
|
+
validation: f.validation,
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
return ok({
|
|
42
|
+
handle: form.handle,
|
|
43
|
+
label: form.label,
|
|
44
|
+
fields: publicFields,
|
|
45
|
+
successMessage: def.settings.successMessage,
|
|
46
|
+
redirectTo: def.settings.redirectTo,
|
|
47
|
+
honeypotField: honeypot,
|
|
48
|
+
})
|
|
49
|
+
} catch (err) {
|
|
50
|
+
return fail(err)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
submit: async (request: Request, rawParams: Record<string, string>): Promise<Response> => {
|
|
55
|
+
try {
|
|
56
|
+
const handle = rawParams.handle
|
|
57
|
+
if (!handle) throw new ValidationError('handle required')
|
|
58
|
+
const form = await forms.findByHandle(handle)
|
|
59
|
+
if (!form || !form.enabled || !form.definition.settings.enabled) {
|
|
60
|
+
throw new NotFoundError('form not found')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const def = form.definition
|
|
64
|
+
const honeypot = def.settings.honeypotField ?? '_hp'
|
|
65
|
+
let body = await request.json().catch(() => ({})) as Record<string, unknown>
|
|
66
|
+
const ip = request.headers.get('cf-connecting-ip')
|
|
67
|
+
?? request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
|
|
68
|
+
?? '0.0.0.0'
|
|
69
|
+
|
|
70
|
+
const beforeSubmit = await runFormBeforeSubmitHooks({
|
|
71
|
+
request,
|
|
72
|
+
form: def,
|
|
73
|
+
payload: body,
|
|
74
|
+
ip,
|
|
75
|
+
headers: request.headers,
|
|
76
|
+
}, options.env)
|
|
77
|
+
if (beforeSubmit.action === 'drop') {
|
|
78
|
+
return ok({
|
|
79
|
+
ok: true,
|
|
80
|
+
message: def.settings.successMessage ?? 'Thank you!',
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
body = beforeSubmit.payload
|
|
84
|
+
|
|
85
|
+
const hp = body[honeypot]
|
|
86
|
+
if (hp !== undefined && hp !== null && String(hp).length > 0) {
|
|
87
|
+
return ok({
|
|
88
|
+
ok: true,
|
|
89
|
+
message: def.settings.successMessage ?? 'Thank you!',
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rate = def.settings.rateLimit ?? { maxPerIp: 10, windowSec: 3600 }
|
|
94
|
+
const rl = await checkRateLimit(db, handle, await hashIp(ip), rate)
|
|
95
|
+
if (!rl.allowed) {
|
|
96
|
+
return new Response(JSON.stringify({
|
|
97
|
+
ok: false,
|
|
98
|
+
error: { code: 'RATE_LIMIT', message: 'Too many submissions' },
|
|
99
|
+
}), {
|
|
100
|
+
status: 429,
|
|
101
|
+
headers: {
|
|
102
|
+
'content-type': 'application/json',
|
|
103
|
+
...(rl.retryAfterSec ? { 'retry-after': String(rl.retryAfterSec) } : {}),
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const { schema, uniqueFields, inputFields } = compileForm(def)
|
|
109
|
+
const parsed = schema.safeParse(body)
|
|
110
|
+
if (!parsed.success) {
|
|
111
|
+
throw new ValidationError('Invalid submission', { issues: parsed.error.issues })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const payload = parsed.data as Record<string, unknown>
|
|
115
|
+
const fileRefs: Array<{ field: string; mediaId: string }> = []
|
|
116
|
+
|
|
117
|
+
for (const field of inputFields) {
|
|
118
|
+
if (field.ui.kind !== 'file') continue
|
|
119
|
+
const mediaId = payload[field.name]
|
|
120
|
+
if (typeof mediaId !== 'string') continue
|
|
121
|
+
const draft = await drafts.findValid(handle, field.name, mediaId)
|
|
122
|
+
if (!draft) throw new ValidationError(`Invalid or expired file for field "${field.name}"`)
|
|
123
|
+
fileRefs.push({ field: field.name, mediaId })
|
|
124
|
+
await drafts.attachToSubmission(draft.id)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const submission = await submissions.create({
|
|
128
|
+
formHandle: handle,
|
|
129
|
+
payload,
|
|
130
|
+
fileRefs,
|
|
131
|
+
meta: {
|
|
132
|
+
ip,
|
|
133
|
+
...(request.headers.get('user-agent') ? { userAgent: request.headers.get('user-agent')! } : {}),
|
|
134
|
+
...(request.headers.get('referer') ? { referer: request.headers.get('referer')! } : {}),
|
|
135
|
+
},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await insertUniqueValues(db, handle, submission.id, payload, uniqueFields)
|
|
140
|
+
} catch (err) {
|
|
141
|
+
await submissions.delete(submission.id)
|
|
142
|
+
throw err
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await enqueueFormProcess(options.queue, submission.id)
|
|
146
|
+
await runFormAfterSubmitHooks({
|
|
147
|
+
request,
|
|
148
|
+
form: def,
|
|
149
|
+
payload,
|
|
150
|
+
submission,
|
|
151
|
+
ip,
|
|
152
|
+
headers: request.headers,
|
|
153
|
+
}, options.env)
|
|
154
|
+
|
|
155
|
+
if (def.settings.redirectTo) {
|
|
156
|
+
return ok({ ok: true, redirect: def.settings.redirectTo })
|
|
157
|
+
}
|
|
158
|
+
return ok({
|
|
159
|
+
ok: true,
|
|
160
|
+
message: def.settings.successMessage ?? 'Thank you!',
|
|
161
|
+
submissionId: submission.id,
|
|
162
|
+
})
|
|
163
|
+
} catch (err) {
|
|
164
|
+
return fail(err)
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { VulseDb } from '../../core/db.js'
|
|
2
|
+
import { FormsRepo, FormUploadDraftsRepo } from '../../core/repos/forms.js'
|
|
3
|
+
import { MediaRepo } from '../../core/repos/media.js'
|
|
4
|
+
import { NotFoundError, ValidationError } from '../../core/errors.js'
|
|
5
|
+
import { fail, ok } from '../envelope.js'
|
|
6
|
+
import { putToR2 } from '../r2.js'
|
|
7
|
+
|
|
8
|
+
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024
|
|
9
|
+
const BLOCKED_MIME = new Set([
|
|
10
|
+
'application/x-msdownload',
|
|
11
|
+
'application/x-executable',
|
|
12
|
+
'application/vnd.microsoft.portable-executable',
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
export interface FormUploadEnv {
|
|
16
|
+
bucket: R2Bucket
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function formUploadRoutes(db: VulseDb, mediaEnv: FormUploadEnv) {
|
|
20
|
+
const forms = new FormsRepo(db)
|
|
21
|
+
const drafts = new FormUploadDraftsRepo(db)
|
|
22
|
+
const media = new MediaRepo(db)
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
upload: async (request: Request, rawParams: Record<string, string>): Promise<Response> => {
|
|
26
|
+
try {
|
|
27
|
+
const handle = rawParams.handle
|
|
28
|
+
if (!handle) throw new ValidationError('handle required')
|
|
29
|
+
const form = await forms.findByHandle(handle)
|
|
30
|
+
if (!form || !form.enabled) throw new NotFoundError('form not found')
|
|
31
|
+
|
|
32
|
+
const formData = await request.formData()
|
|
33
|
+
const field = formData.get('field')
|
|
34
|
+
const fileEntry = formData.get('file')
|
|
35
|
+
if (typeof field !== 'string' || !field) throw new ValidationError('field required')
|
|
36
|
+
if (!fileEntry || typeof fileEntry === 'string') throw new ValidationError('file required')
|
|
37
|
+
|
|
38
|
+
const file = fileEntry as File
|
|
39
|
+
const fieldDef = form.definition.fields.find((f) => f.name === field)
|
|
40
|
+
if (!fieldDef || fieldDef.ui.kind !== 'file') {
|
|
41
|
+
throw new ValidationError(`Unknown file field "${field}"`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const maxBytes = fieldDef.ui.maxBytes ?? DEFAULT_MAX_BYTES
|
|
45
|
+
if (file.size > maxBytes) throw new ValidationError(`File too large (max ${maxBytes} bytes)`)
|
|
46
|
+
if (BLOCKED_MIME.has(file.type)) throw new ValidationError(`File type not allowed: ${file.type}`)
|
|
47
|
+
|
|
48
|
+
if (fieldDef.ui.accept?.length) {
|
|
49
|
+
const okMime = fieldDef.ui.accept.some((a) =>
|
|
50
|
+
a.endsWith('/*') ? file.type.startsWith(a.slice(0, -1)) : file.type === a,
|
|
51
|
+
)
|
|
52
|
+
if (!okMime) throw new ValidationError(`File type not allowed: ${file.type}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const buf = await file.arrayBuffer()
|
|
56
|
+
const { key } = await putToR2({ bucket: mediaEnv.bucket }, buf, file.type || 'application/octet-stream')
|
|
57
|
+
const row = await media.create({
|
|
58
|
+
r2Key: key,
|
|
59
|
+
mime: file.type || 'application/octet-stream',
|
|
60
|
+
size: file.size,
|
|
61
|
+
uploadedBy: null,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const ttlHours = form.definition.settings.uploadDraftTtlHours ?? 24
|
|
65
|
+
const expiresAt = new Date(Date.now() + ttlHours * 3600_000)
|
|
66
|
+
const draft = await drafts.create({
|
|
67
|
+
formHandle: handle,
|
|
68
|
+
fieldName: field,
|
|
69
|
+
mediaId: row.id,
|
|
70
|
+
expiresAt,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
return ok({ mediaId: row.id, draftId: draft.id, expiresAt: expiresAt.toISOString() })
|
|
74
|
+
} catch (err) {
|
|
75
|
+
return fail(err)
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function purgeExpiredFormUploadDrafts(
|
|
82
|
+
db: VulseDb,
|
|
83
|
+
bucket: R2Bucket,
|
|
84
|
+
): Promise<{ purged: number }> {
|
|
85
|
+
const drafts = new FormUploadDraftsRepo(db)
|
|
86
|
+
const media = new MediaRepo(db)
|
|
87
|
+
const { deleteFromR2 } = await import('../r2.js')
|
|
88
|
+
const expired = await drafts.listExpired(new Date())
|
|
89
|
+
let purged = 0
|
|
90
|
+
for (const draft of expired) {
|
|
91
|
+
const row = await media.findById(draft.mediaId)
|
|
92
|
+
if (row) {
|
|
93
|
+
await deleteFromR2({ bucket }, row.r2Key)
|
|
94
|
+
await media.hardDelete(row.id)
|
|
95
|
+
}
|
|
96
|
+
await drafts.delete(draft.id)
|
|
97
|
+
purged++
|
|
98
|
+
}
|
|
99
|
+
return { purged }
|
|
100
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { z } from 'astro/zod'
|
|
2
|
+
import type { Auth } from '../better-auth.js'
|
|
3
|
+
import type { VulseDb } from '../../core/db.js'
|
|
4
|
+
import { FormsRepo, SubmissionsRepo } from '../../core/repos/forms.js'
|
|
5
|
+
import { FormDefinitionSchema } from '../../core/forms/definition.js'
|
|
6
|
+
import { NotFoundError } from '../../core/errors.js'
|
|
7
|
+
import { defineHandler } from '../handler.js'
|
|
8
|
+
|
|
9
|
+
const paramsHandle = z.object({ handle: z.string() })
|
|
10
|
+
const paramsHandleId = z.object({ handle: z.string(), id: z.string() })
|
|
11
|
+
|
|
12
|
+
export function formsRoutes(db: VulseDb, auth: Auth) {
|
|
13
|
+
const forms = new FormsRepo(db)
|
|
14
|
+
const submissions = new SubmissionsRepo(db)
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
list: defineHandler(auth, { requireRole: ['admin', 'editor'] }, async () => forms.list()),
|
|
18
|
+
|
|
19
|
+
create: defineHandler(auth, {
|
|
20
|
+
body: FormDefinitionSchema,
|
|
21
|
+
requireRole: ['admin'],
|
|
22
|
+
}, async ({ body }) => forms.create(body)),
|
|
23
|
+
|
|
24
|
+
get: defineHandler(auth, {
|
|
25
|
+
params: paramsHandle,
|
|
26
|
+
requireRole: ['admin', 'editor'],
|
|
27
|
+
}, async ({ params }) => {
|
|
28
|
+
const row = await forms.findByHandle(params.handle)
|
|
29
|
+
if (!row) throw new NotFoundError('form not found')
|
|
30
|
+
return row
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
update: defineHandler(auth, {
|
|
34
|
+
params: paramsHandle,
|
|
35
|
+
body: FormDefinitionSchema,
|
|
36
|
+
requireRole: ['admin'],
|
|
37
|
+
}, async ({ params, body }) => forms.update(params.handle, body)),
|
|
38
|
+
|
|
39
|
+
delete: defineHandler(auth, {
|
|
40
|
+
params: paramsHandle,
|
|
41
|
+
requireRole: ['admin'],
|
|
42
|
+
}, async ({ params }) => {
|
|
43
|
+
await forms.delete(params.handle)
|
|
44
|
+
return null
|
|
45
|
+
}),
|
|
46
|
+
|
|
47
|
+
listSubmissions: defineHandler(auth, {
|
|
48
|
+
params: paramsHandle,
|
|
49
|
+
requireRole: ['admin', 'editor'],
|
|
50
|
+
}, async ({ params, url }) => {
|
|
51
|
+
const limit = Number(url.searchParams.get('limit') ?? '50')
|
|
52
|
+
const offset = Number(url.searchParams.get('offset') ?? '0')
|
|
53
|
+
return submissions.list({
|
|
54
|
+
formHandle: params.handle,
|
|
55
|
+
limit: Number.isFinite(limit) ? limit : 50,
|
|
56
|
+
offset: Number.isFinite(offset) ? offset : 0,
|
|
57
|
+
})
|
|
58
|
+
}),
|
|
59
|
+
|
|
60
|
+
getSubmission: defineHandler(auth, {
|
|
61
|
+
params: paramsHandleId,
|
|
62
|
+
requireRole: ['admin', 'editor'],
|
|
63
|
+
}, async ({ params }) => {
|
|
64
|
+
const row = await submissions.findById(params.id)
|
|
65
|
+
if (!row || row.formHandle !== params.handle) throw new NotFoundError('submission not found')
|
|
66
|
+
return row
|
|
67
|
+
}),
|
|
68
|
+
|
|
69
|
+
deleteSubmission: defineHandler(auth, {
|
|
70
|
+
params: paramsHandleId,
|
|
71
|
+
requireRole: ['admin', 'editor'],
|
|
72
|
+
}, async ({ params }) => {
|
|
73
|
+
const row = await submissions.findById(params.id)
|
|
74
|
+
if (!row || row.formHandle !== params.handle) throw new NotFoundError('submission not found')
|
|
75
|
+
await submissions.delete(params.id)
|
|
76
|
+
return null
|
|
77
|
+
}),
|
|
78
|
+
|
|
79
|
+
bulkDeleteSubmissions: defineHandler(auth, {
|
|
80
|
+
params: paramsHandle,
|
|
81
|
+
body: z.object({ ids: z.array(z.string()).min(1) }),
|
|
82
|
+
requireRole: ['admin', 'editor'],
|
|
83
|
+
}, async ({ params, body }) => {
|
|
84
|
+
const deleted = await submissions.deleteMany(body.ids)
|
|
85
|
+
return { deleted }
|
|
86
|
+
}),
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { VulseDb } from '../../core/db.js'
|
|
2
|
+
import { GlobalsRepo } from '../../core/repos/globals.js'
|
|
3
|
+
import { NotFoundError } from '../../core/errors.js'
|
|
4
|
+
import { fail, ok } from '../envelope.js'
|
|
5
|
+
|
|
6
|
+
export function globalsPublicRoutes(db: VulseDb) {
|
|
7
|
+
const globals = new GlobalsRepo(db)
|
|
8
|
+
|
|
9
|
+
return {
|
|
10
|
+
list: async (): Promise<Response> => {
|
|
11
|
+
try {
|
|
12
|
+
return ok(await globals.publicValues())
|
|
13
|
+
} catch (err) {
|
|
14
|
+
return fail(err)
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
get: async (_request: Request, rawParams: Record<string, string>): Promise<Response> => {
|
|
19
|
+
try {
|
|
20
|
+
const handle = rawParams.handle
|
|
21
|
+
if (!handle) throw new NotFoundError('global set not found')
|
|
22
|
+
const content = await globals.publicValue(handle)
|
|
23
|
+
if (content === null) throw new NotFoundError('global set not found')
|
|
24
|
+
return ok(content)
|
|
25
|
+
} catch (err) {
|
|
26
|
+
return fail(err)
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|