kilo-cms 0.1.0

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 (179) hide show
  1. package/README.md +186 -0
  2. package/drizzle.kilo.config.ts +17 -0
  3. package/package.json +97 -0
  4. package/src/admin/AiSettingsEditor.tsx +48 -0
  5. package/src/admin/AiWritingAssistant.tsx +159 -0
  6. package/src/admin/ApiTokensEditor.tsx +69 -0
  7. package/src/admin/ArrayField.tsx +118 -0
  8. package/src/admin/BlocksField.tsx +131 -0
  9. package/src/admin/Button.tsx +31 -0
  10. package/src/admin/CloseButton.tsx +18 -0
  11. package/src/admin/CmsLocalesProvider.tsx +19 -0
  12. package/src/admin/CmsSidebar.tsx +147 -0
  13. package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
  14. package/src/admin/CmsThemeProvider.tsx +100 -0
  15. package/src/admin/CollectionListEditor.tsx +278 -0
  16. package/src/admin/CollectionRecordEditor.tsx +420 -0
  17. package/src/admin/ContentEditorForm.tsx +21 -0
  18. package/src/admin/ContentViewSettings.tsx +55 -0
  19. package/src/admin/DashboardBuilder.tsx +284 -0
  20. package/src/admin/DragHandle.tsx +3 -0
  21. package/src/admin/DynamicField.tsx +517 -0
  22. package/src/admin/EditorHeader.tsx +13 -0
  23. package/src/admin/EditorSkeleton.tsx +27 -0
  24. package/src/admin/EmailSettingsEditor.tsx +80 -0
  25. package/src/admin/Field.tsx +215 -0
  26. package/src/admin/FilterBuilder.tsx +62 -0
  27. package/src/admin/GroupField.tsx +61 -0
  28. package/src/admin/ImageUploader.tsx +198 -0
  29. package/src/admin/ListingSettingsModal.tsx +68 -0
  30. package/src/admin/ListingView.tsx +123 -0
  31. package/src/admin/LocalesEditor.tsx +86 -0
  32. package/src/admin/LocalizedField.tsx +46 -0
  33. package/src/admin/MapPicker.tsx +53 -0
  34. package/src/admin/MediaLibrary.tsx +483 -0
  35. package/src/admin/MediaUploader.tsx +55 -0
  36. package/src/admin/Portal.tsx +10 -0
  37. package/src/admin/PublishingPanel.tsx +102 -0
  38. package/src/admin/RecordMeta.tsx +24 -0
  39. package/src/admin/ReviewQueueEditor.tsx +103 -0
  40. package/src/admin/RichTextEditor.tsx +49 -0
  41. package/src/admin/RoleEditor.tsx +121 -0
  42. package/src/admin/RolesEditor.tsx +42 -0
  43. package/src/admin/RowActionsMenu.tsx +64 -0
  44. package/src/admin/ScheduledEditor.tsx +155 -0
  45. package/src/admin/SeoSettingsEditor.tsx +53 -0
  46. package/src/admin/SingleRecordEditor.tsx +215 -0
  47. package/src/admin/SiteSettingsEditor.tsx +96 -0
  48. package/src/admin/Toast.tsx +45 -0
  49. package/src/admin/UserEditor.tsx +64 -0
  50. package/src/admin/UsersEditor.tsx +37 -0
  51. package/src/admin/WebhooksEditor.tsx +115 -0
  52. package/src/admin/admin.css +6637 -0
  53. package/src/admin/globals.css +14 -0
  54. package/src/admin/pages/admin-index.tsx +5 -0
  55. package/src/admin/pages/ai-settings.tsx +9 -0
  56. package/src/admin/pages/api-tokens.tsx +10 -0
  57. package/src/admin/pages/collection-list.tsx +22 -0
  58. package/src/admin/pages/collection-record.tsx +31 -0
  59. package/src/admin/pages/dashboard.tsx +10 -0
  60. package/src/admin/pages/email-settings.tsx +10 -0
  61. package/src/admin/pages/layout.tsx +24 -0
  62. package/src/admin/pages/locales-settings.tsx +10 -0
  63. package/src/admin/pages/login.tsx +25 -0
  64. package/src/admin/pages/media.tsx +9 -0
  65. package/src/admin/pages/review-queue.tsx +20 -0
  66. package/src/admin/pages/role-edit.tsx +11 -0
  67. package/src/admin/pages/roles.tsx +10 -0
  68. package/src/admin/pages/scheduled.tsx +17 -0
  69. package/src/admin/pages/seo-settings.tsx +9 -0
  70. package/src/admin/pages/setup-admin.tsx +76 -0
  71. package/src/admin/pages/single-record.tsx +24 -0
  72. package/src/admin/pages/site-settings.tsx +9 -0
  73. package/src/admin/pages/user-edit.tsx +11 -0
  74. package/src/admin/pages/user-new.tsx +10 -0
  75. package/src/admin/pages/users.tsx +10 -0
  76. package/src/admin/pages/webhooks-settings.tsx +10 -0
  77. package/src/admin/useKeyedRows.ts +35 -0
  78. package/src/ai-config.ts +35 -0
  79. package/src/ai-secrets.ts +17 -0
  80. package/src/ai-types.ts +10 -0
  81. package/src/ai-writer.ts +84 -0
  82. package/src/api-tokens.ts +25 -0
  83. package/src/auth-client.ts +5 -0
  84. package/src/auth.ts +33 -0
  85. package/src/calendar-grid.ts +12 -0
  86. package/src/cli/index.mjs +149 -0
  87. package/src/cms-appearance.ts +55 -0
  88. package/src/collections/content-view.ts +69 -0
  89. package/src/collections/define.ts +17 -0
  90. package/src/collections/filters.ts +92 -0
  91. package/src/collections/index.ts +11 -0
  92. package/src/collections/join.ts +64 -0
  93. package/src/collections/locale.ts +22 -0
  94. package/src/collections/query.ts +160 -0
  95. package/src/collections/registry.ts +88 -0
  96. package/src/collections/schedule.ts +12 -0
  97. package/src/collections/server.ts +12 -0
  98. package/src/collections/single.ts +26 -0
  99. package/src/collections/table.ts +14 -0
  100. package/src/collections/types.ts +336 -0
  101. package/src/collections/validation.ts +217 -0
  102. package/src/collections/values.ts +181 -0
  103. package/src/collections/workflow.ts +90 -0
  104. package/src/config.ts +34 -0
  105. package/src/content-types.ts +218 -0
  106. package/src/dashboard.ts +39 -0
  107. package/src/db.ts +50 -0
  108. package/src/email.ts +39 -0
  109. package/src/format.ts +4 -0
  110. package/src/listQuery.ts +23 -0
  111. package/src/locale-settings.ts +23 -0
  112. package/src/media-storage.ts +19 -0
  113. package/src/permissions.ts +37 -0
  114. package/src/publishing-workflow-db.ts +11 -0
  115. package/src/publishing-workflow.ts +20 -0
  116. package/src/richtext.ts +5 -0
  117. package/src/routes/admin/ai/settings/route.ts +20 -0
  118. package/src/routes/admin/ai/write/route.ts +21 -0
  119. package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
  120. package/src/routes/admin/api-tokens/route.ts +26 -0
  121. package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
  122. package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
  123. package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
  124. package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
  125. package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
  126. package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
  127. package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
  128. package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
  129. package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
  130. package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
  131. package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
  132. package/src/routes/admin/collections/[collection]/route.ts +88 -0
  133. package/src/routes/admin/collections/route.ts +13 -0
  134. package/src/routes/admin/content-views/[type]/route.ts +25 -0
  135. package/src/routes/admin/dashboard/data/route.ts +39 -0
  136. package/src/routes/admin/dashboard/route.ts +30 -0
  137. package/src/routes/admin/email-settings/route.ts +49 -0
  138. package/src/routes/admin/email-settings/test/route.ts +18 -0
  139. package/src/routes/admin/locales/[code]/route.ts +38 -0
  140. package/src/routes/admin/locales/reorder/route.ts +18 -0
  141. package/src/routes/admin/locales/route.ts +46 -0
  142. package/src/routes/admin/media/[id]/restore/route.ts +13 -0
  143. package/src/routes/admin/media/[id]/route.ts +56 -0
  144. package/src/routes/admin/media/bulk/route.ts +46 -0
  145. package/src/routes/admin/media/folders/[id]/route.ts +34 -0
  146. package/src/routes/admin/media/route.ts +41 -0
  147. package/src/routes/admin/media/stats/route.ts +14 -0
  148. package/src/routes/admin/roles/[id]/route.ts +43 -0
  149. package/src/routes/admin/roles/route.ts +34 -0
  150. package/src/routes/admin/settings/route.ts +45 -0
  151. package/src/routes/admin/single/[type]/route.ts +54 -0
  152. package/src/routes/admin/singles/route.ts +15 -0
  153. package/src/routes/admin/upload/route.ts +55 -0
  154. package/src/routes/admin/users/[id]/route.ts +24 -0
  155. package/src/routes/admin/users/route.ts +24 -0
  156. package/src/routes/admin/webhooks/[id]/route.ts +38 -0
  157. package/src/routes/admin/webhooks/route.ts +26 -0
  158. package/src/routes/cron/publish-scheduled.ts +37 -0
  159. package/src/routes/setup/admin.ts +19 -0
  160. package/src/schema/admin-ui.ts +18 -0
  161. package/src/schema/auth.ts +49 -0
  162. package/src/schema/developer-tools.ts +44 -0
  163. package/src/schema/helpers.ts +35 -0
  164. package/src/schema/index.ts +33 -0
  165. package/src/schema/locales.ts +13 -0
  166. package/src/schema/media.ts +22 -0
  167. package/src/schema/rbac.ts +11 -0
  168. package/src/schema/settings.ts +28 -0
  169. package/src/secrets.ts +26 -0
  170. package/src/seo.ts +69 -0
  171. package/src/storage/index.ts +20 -0
  172. package/src/storage/local.ts +27 -0
  173. package/src/storage/s3-compatible.ts +31 -0
  174. package/src/storage/types.ts +8 -0
  175. package/src/storage/vercel-blob.ts +17 -0
  176. package/src/useBodyScrollLock.ts +12 -0
  177. package/src/useLocalStorageState.ts +26 -0
  178. package/src/webhook-events.ts +12 -0
  179. package/src/webhooks.ts +30 -0
package/src/format.ts ADDED
@@ -0,0 +1,4 @@
1
+ export function formatDateTime(value: string | Date | null | undefined) {
2
+ if (!value) return '—'
3
+ return new Date(value).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' })
4
+ }
@@ -0,0 +1,23 @@
1
+ export type ListQuery = {
2
+ q: string
3
+ filtersRaw: string
4
+ sortBy: string
5
+ sortDir: 'asc' | 'desc'
6
+ page: number
7
+ pageSize: number | 'all'
8
+ trashed: boolean
9
+ }
10
+
11
+ export function parseListQuery(url: string, defaultSortBy: string): ListQuery {
12
+ const params = new URL(url).searchParams
13
+ const pageSizeRaw = params.get('pageSize')
14
+ return {
15
+ q: params.get('q')?.trim().slice(0, 100) || '',
16
+ filtersRaw: params.get('filters') || '[]',
17
+ sortBy: params.get('sortBy') || defaultSortBy,
18
+ sortDir: params.get('sortDir') === 'desc' ? 'desc' : 'asc',
19
+ page: Math.max(1, Math.trunc(Number(params.get('page')) || 1)),
20
+ pageSize: pageSizeRaw === 'all' ? 'all' : Math.min(100, Math.max(1, Math.trunc(Number(pageSizeRaw)) || 20)),
21
+ trashed: params.get('trashed') === '1',
22
+ }
23
+ }
@@ -0,0 +1,23 @@
1
+ import 'server-only'
2
+ import { cache } from 'react'
3
+ import { asc } from 'drizzle-orm'
4
+ import { db } from './db'
5
+ import { cmsLocales } from './schema'
6
+ import { fallbackLocaleConfig, type LocaleConfig } from './collections/locale'
7
+
8
+ /** The live, admin-editable locale list — request-deduplicated the same way `getSiteSettings`
9
+ * is (`src/lib/seo.ts`), and with the same graceful-fallback-on-error posture: an empty table
10
+ * or a DB error both fall back to `fallbackLocaleConfig`, never a hard failure. */
11
+ export const getLocaleConfig = cache(async function getLocaleConfig(): Promise<LocaleConfig> {
12
+ try {
13
+ const rows = await db.select().from(cmsLocales).orderBy(asc(cmsLocales.sortOrder))
14
+ if (!rows.length) return fallbackLocaleConfig
15
+ return {
16
+ codes: rows.map((row: any) => row.code),
17
+ default: rows.find((row: any) => row.isDefault)?.code ?? rows[0].code,
18
+ labels: Object.fromEntries(rows.map((row: any) => [row.code, row.label])),
19
+ }
20
+ } catch {
21
+ return fallbackLocaleConfig
22
+ }
23
+ })
@@ -0,0 +1,19 @@
1
+ import 'server-only'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { storage } from './storage'
4
+
5
+ // Files live in one flat directory/key-space, independent of which folder they're organized
6
+ // under in the media library. `folder` is pure metadata — moving a file between folders must
7
+ // never change its URL, or every place that already saved that URL (a content field, rich
8
+ // text, a raw <img> tag) would break the moment the file gets reorganized.
9
+
10
+ export async function uploadFile(data: Buffer, contentType: string, extension: string): Promise<{ url: string }> {
11
+ const key = `${randomUUID()}.${extension}`
12
+ return storage.put(key, data, contentType)
13
+ }
14
+
15
+ /** Best-effort delete of an uploaded file's stored copy. Swallows a missing file — the DB row
16
+ * being gone is what matters; the file may already be absent (e.g. a manual cleanup). */
17
+ export async function removeUploadedFile(url: string) {
18
+ await storage.remove(url)
19
+ }
@@ -0,0 +1,37 @@
1
+ import { headers } from 'next/headers'
2
+ import { eq } from 'drizzle-orm'
3
+ import { auth, db } from './db'
4
+ import { roles } from './schema'
5
+ import { emptyRolePermissions, fullRolePermissions, type ActionKey, type ContentTypeKey, type StoredRolePermissions, type SystemPermission } from './content-types'
6
+
7
+ export { contentTypeDefs, systemPermissions, systemPermissionLabels, actionLabels, type ActionKey, type ContentTypeKey, type SystemPermission } from './content-types'
8
+
9
+ export async function getSessionWithPermissions() {
10
+ const session = await auth.api.getSession({ headers: await headers() })
11
+ if (!session) return null
12
+ const [role] = await db.select().from(roles).where(eq(roles.id, session.user.role)).limit(1)
13
+ const isSystem = role?.isSystem ?? false
14
+ const permissions: StoredRolePermissions = isSystem ? fullRolePermissions() : (role?.permissions as StoredRolePermissions | undefined) ?? emptyRolePermissions
15
+
16
+ return {
17
+ session,
18
+ isSystem,
19
+ can(type: ContentTypeKey, action: ActionKey) { return permissions.contentTypes[type]?.actions.includes(action) ?? false },
20
+ allowedFields(type: ContentTypeKey) { return permissions.contentTypes[type]?.fields ?? [] },
21
+ hasSystem(permission: SystemPermission) { return permissions.system.includes(permission) },
22
+ }
23
+ }
24
+
25
+ export type SessionWithPermissions = NonNullable<Awaited<ReturnType<typeof getSessionWithPermissions>>>
26
+
27
+ export async function requireAction(type: ContentTypeKey, action: ActionKey) {
28
+ const result = await getSessionWithPermissions()
29
+ if (!result || !result.can(type, action)) return null
30
+ return result
31
+ }
32
+
33
+ export async function requireSystem(permission: SystemPermission) {
34
+ const result = await getSessionWithPermissions()
35
+ if (!result || !result.hasSystem(permission)) return null
36
+ return result.session
37
+ }
@@ -0,0 +1,11 @@
1
+ import 'server-only'
2
+
3
+ import { eq } from 'drizzle-orm'
4
+ import { db } from './db'
5
+ import { siteSettings } from './schema'
6
+ import { normalizePublishingWorkflow, type ApprovalContentType } from './publishing-workflow'
7
+
8
+ export async function contentRequiresApproval(type: ApprovalContentType) {
9
+ const [settings] = await db.select({ publishingWorkflow: siteSettings.publishingWorkflow }).from(siteSettings).where(eq(siteSettings.id, 'default')).limit(1)
10
+ return normalizePublishingWorkflow(settings?.publishingWorkflow).approvalRequired.includes(type)
11
+ }
@@ -0,0 +1,20 @@
1
+ import { reviewableSlugs } from './collections/registry'
2
+ import type { EntrySlug } from './collections/types'
3
+
4
+ /** Every review-enabled entry can be put behind an approval gate. */
5
+ export type ApprovalContentType = EntrySlug
6
+ export const allApprovalContentTypes: ApprovalContentType[] = [...reviewableSlugs]
7
+ export type PublishingWorkflow = { approvalRequired: ApprovalContentType[] }
8
+
9
+ // Literal, not derived: this is the historical default and matches cms_site_settings'
10
+ // column default. Changing it would silently re-gate existing installs.
11
+ export const defaultPublishingWorkflow: PublishingWorkflow = { approvalRequired: ['services', 'projects', 'blog'] }
12
+
13
+ export function normalizePublishingWorkflow(value: unknown): PublishingWorkflow {
14
+ if (!value || typeof value !== 'object') return defaultPublishingWorkflow
15
+ const candidate = value as { approvalRequired?: unknown }
16
+ const approvalRequired = Array.isArray(candidate.approvalRequired)
17
+ ? candidate.approvalRequired.filter((item): item is ApprovalContentType => typeof item === 'string' && (allApprovalContentTypes as string[]).includes(item))
18
+ : defaultPublishingWorkflow.approvalRequired
19
+ return { approvalRequired }
20
+ }
@@ -0,0 +1,5 @@
1
+ export type RichTextMark = { type: 'bold' | 'italic' | 'strike' | 'code' | 'link'; attrs?: { href?: string } }
2
+ export type RichTextNode = { type: string; text?: string; attrs?: Record<string, unknown>; marks?: RichTextMark[]; content?: RichTextNode[] }
3
+ export type RichTextDocument = { type: 'doc'; content?: RichTextNode[] }
4
+
5
+ export const emptyRichText: RichTextDocument = { type: 'doc', content: [{ type: 'paragraph' }] }
@@ -0,0 +1,20 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { getAiProviderAvailability, saveAiKeys } from '../../../../ai-config'
4
+ import { aiProviders } from '../../../../ai-types'
5
+ import { getSessionWithPermissions, requireSystem } from '../../../../permissions'
6
+
7
+ export async function GET() {
8
+ const result = await getSessionWithPermissions()
9
+ if (!result) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
10
+ return NextResponse.json({ providers: await getAiProviderAvailability(), canManage: result.hasSystem('ai.manage') })
11
+ }
12
+
13
+ const schema = z.object({ keys: z.object({ manus: z.string().max(500).optional(), deepseek: z.string().max(500).optional(), openai: z.string().max(500).optional(), anthropic: z.string().max(500).optional() }).default({}), clear: z.array(z.enum(aiProviders)).max(aiProviders.length).default([]) })
14
+
15
+ export async function PUT(request: Request) {
16
+ if (!await requireSystem('ai.manage')) return NextResponse.json({ error: 'Only administrators can change AI keys.' }, { status: 403 })
17
+ const parsed = schema.safeParse(await request.json())
18
+ if (!parsed.success) return NextResponse.json({ error: 'Invalid AI key settings.' }, { status: 400 })
19
+ try { return NextResponse.json({ providers: await saveAiKeys(parsed.data.keys, parsed.data.clear), canManage: true }) } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : 'Could not save AI key settings.' }, { status: 400 }) }
20
+ }
@@ -0,0 +1,21 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { pollManusWrite, startAiWrite } from '../../../../ai-writer'
4
+ import { aiProviders } from '../../../../ai-types'
5
+ import { getSessionWithPermissions } from '../../../../permissions'
6
+
7
+ const input = z.object({ provider: z.enum(aiProviders), action: z.enum(['prompt', 'continue', 'improve', 'summarize']), prompt: z.string().trim().max(4_000).optional(), selection: z.string().max(12_000).optional(), documentText: z.string().max(16_000).optional() }).superRefine((value, context) => { if (value.action === 'prompt' && !value.prompt) context.addIssue({ code: 'custom', path: ['prompt'], message: 'Write an instruction for AI.' }); if (['improve', 'summarize'].includes(value.action) && !value.selection?.trim()) context.addIssue({ code: 'custom', path: ['selection'], message: 'Select text in the editor first.' }) })
8
+
9
+ export async function POST(request: Request) {
10
+ if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
11
+ const parsed = input.safeParse(await request.json())
12
+ if (!parsed.success) return NextResponse.json({ error: parsed.error.issues[0]?.message || 'Invalid AI request.' }, { status: 400 })
13
+ try { return NextResponse.json(await startAiWrite(parsed.data.provider, parsed.data)) } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : 'AI provider request failed.' }, { status: 502 }) }
14
+ }
15
+
16
+ export async function GET(request: Request) {
17
+ if (!await getSessionWithPermissions()) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
18
+ const taskId = new URL(request.url).searchParams.get('taskId') || ''
19
+ if (!/^[A-Za-z0-9]{22}$/.test(taskId)) return NextResponse.json({ error: 'Invalid Manus task.' }, { status: 400 })
20
+ try { return NextResponse.json(await pollManusWrite(taskId)) } catch (error) { return NextResponse.json({ error: error instanceof Error ? error.message : 'Could not check Manus task.' }, { status: 502 }) }
21
+ }
@@ -0,0 +1,12 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { NextResponse } from 'next/server'
3
+ import { db } from '../../../../db'
4
+ import { apiTokens } from '../../../../schema'
5
+ import { requireSystem } from '../../../../permissions'
6
+
7
+ export async function DELETE(request: Request, { params }: { params: Promise<{ id: string }> }) {
8
+ if (!await requireSystem('developers.manage')) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9
+ const { id } = await params
10
+ await db.delete(apiTokens).where(eq(apiTokens.id, id))
11
+ return NextResponse.json({ ok: true })
12
+ }
@@ -0,0 +1,26 @@
1
+ import { desc } from 'drizzle-orm'
2
+ import { NextResponse } from 'next/server'
3
+ import { z } from 'zod'
4
+ import { db } from '../../../db'
5
+ import { apiTokens } from '../../../schema'
6
+ import { requireSystem } from '../../../permissions'
7
+ import { generateToken } from '../../../api-tokens'
8
+
9
+ export async function GET() {
10
+ const session = await requireSystem('developers.manage')
11
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
12
+ const rows = await db.select({ id: apiTokens.id, name: apiTokens.name, tokenPrefix: apiTokens.tokenPrefix, lastUsedAt: apiTokens.lastUsedAt, createdAt: apiTokens.createdAt }).from(apiTokens).orderBy(desc(apiTokens.createdAt))
13
+ return NextResponse.json(rows)
14
+ }
15
+
16
+ const schema = z.object({ name: z.string().trim().min(1).max(80) })
17
+
18
+ export async function POST(request: Request) {
19
+ const session = await requireSystem('developers.manage')
20
+ if (!session) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
21
+ const parsed = schema.safeParse(await request.json())
22
+ if (!parsed.success) return NextResponse.json({ error: 'Give the token a name.' }, { status: 400 })
23
+ const { raw, hash, prefix } = generateToken()
24
+ const [token] = await db.insert(apiTokens).values({ id: crypto.randomUUID(), name: parsed.data.name, tokenHash: hash, tokenPrefix: prefix, createdBy: session.user.id }).returning()
25
+ return NextResponse.json({ id: token.id, name: token.name, tokenPrefix: token.tokenPrefix, createdAt: token.createdAt, token: raw }, { status: 201 })
26
+ }
@@ -0,0 +1,22 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { requireAction } from '../../../../../../permissions'
3
+ import { getCollection } from '../../../../../../collections/registry'
4
+ import { findRow, updateRow } from '../../../../../../collections/query'
5
+ import { fireWebhook, transitions } from '../../../../../../collections/workflow'
6
+
7
+ const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
8
+ const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
9
+
10
+ export async function PATCH(_request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
11
+ const { collection, id } = await params
12
+ const config = getCollection(collection)
13
+ if (!config?.workflow?.review) return notFound()
14
+ const result = await requireAction(config.slug, 'approve')
15
+ if (!result) return unauthorized()
16
+ const existing = await findRow(config.slug, id)
17
+ if (!existing) return notFound()
18
+ if (existing.reviewStatus !== 'reviewed') return NextResponse.json({ error: 'This entry must pass review before it can be approved.' }, { status: 409 })
19
+ const row = await updateRow(config.slug, id, transitions.approve(result.session.user.id))
20
+ if (row) fireWebhook(config, 'updated', row)
21
+ return row ? NextResponse.json(row) : notFound()
22
+ }
@@ -0,0 +1,36 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { requireAction } from '../../../../../../permissions'
3
+ import { getCollection } from '../../../../../../collections/registry'
4
+ import { duplicateResetValues, fireWebhook } from '../../../../../../collections/workflow'
5
+ import { findRow, insertRow, isUniqueViolation, nextSortOrder, type Row } from '../../../../../../collections/query'
6
+
7
+ const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
8
+ const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
9
+
10
+ export async function POST(_request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
11
+ const { collection, id } = await params
12
+ const config = getCollection(collection)
13
+ if (!config?.workflow?.duplicate) return notFound()
14
+ const result = await requireAction(config.slug, 'create')
15
+ if (!result) return unauthorized()
16
+ const source = await findRow(config.slug, id)
17
+ if (!source) return notFound()
18
+
19
+ const newId = crypto.randomUUID()
20
+ const copy: Row = { ...source, id: newId, createdAt: new Date(), updatedAt: new Date() }
21
+ const titleField = config.fields.find((field) => field.key === config.titleField)
22
+ if (titleField && (titleField.type === 'text' || titleField.type === 'textarea')) copy[config.titleField] = `${String(source[config.titleField] ?? '')} (Copy)`
23
+ for (const field of config.fields) {
24
+ if (field.type === 'text' && field.format === 'slug') copy[field.key] = `${String(source[field.key] ?? 'copy')}-copy-${newId.slice(0, 6)}`
25
+ }
26
+ Object.assign(copy, duplicateResetValues(config, result.session.user.id, config.workflow.sortOrder ? await nextSortOrder(config.slug) : 0))
27
+
28
+ try {
29
+ const row = await insertRow(config.slug, copy)
30
+ fireWebhook(config, 'created', row)
31
+ return NextResponse.json(row, { status: 201 })
32
+ } catch (error) {
33
+ if (isUniqueViolation(error)) return NextResponse.json({ error: 'A record with one of those unique values already exists.' }, { status: 409 })
34
+ throw error
35
+ }
36
+ }
@@ -0,0 +1,19 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { requireAction } from '../../../../../../permissions'
3
+ import { getCollection } from '../../../../../../collections/registry'
4
+ import { updateRow } from '../../../../../../collections/query'
5
+ import { fireWebhook, transitions } from '../../../../../../collections/workflow'
6
+
7
+ const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
8
+ const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
9
+
10
+ export async function POST(_request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
11
+ const { collection, id } = await params
12
+ const config = getCollection(collection)
13
+ if (!config?.workflow?.trash) return notFound()
14
+ const result = await requireAction(config.slug, 'delete')
15
+ if (!result) return unauthorized()
16
+ const row = await updateRow(config.slug, id, transitions.restore())
17
+ if (row) fireWebhook(config, 'updated', row)
18
+ return row ? NextResponse.json(row) : notFound()
19
+ }
@@ -0,0 +1,28 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { getSessionWithPermissions } from '../../../../../../permissions'
4
+ import { getCollection } from '../../../../../../collections/registry'
5
+ import { findRow, updateRow } from '../../../../../../collections/query'
6
+ import { fireWebhook, transitions } from '../../../../../../collections/workflow'
7
+
8
+ const schema = z.object({ decision: z.enum(['approve', 'request_changes']) })
9
+ const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
10
+ const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
11
+
12
+ export async function PATCH(request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
13
+ const { collection, id } = await params
14
+ const config = getCollection(collection)
15
+ if (!config?.workflow?.review) return notFound()
16
+ const result = await getSessionWithPermissions()
17
+ if (!result || !result.can(config.slug, 'review')) return unauthorized()
18
+ const parsed = schema.safeParse(await request.json())
19
+ if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
20
+ const existing = await findRow(config.slug, id)
21
+ if (!existing) return notFound()
22
+ if (existing.reviewStatus !== 'in_review') return NextResponse.json({ error: 'This entry is not awaiting review.' }, { status: 409 })
23
+ if (!result.isSystem && existing.reviewerId !== result.session.user.id) return NextResponse.json({ error: 'Only the assigned reviewer can review this.' }, { status: 403 })
24
+ const set = parsed.data.decision === 'approve' ? transitions.passReview(result.session.user.id) : transitions.requestChanges(result.session.user.id)
25
+ const row = await updateRow(config.slug, id, set)
26
+ if (row) fireWebhook(config, 'updated', row)
27
+ return row ? NextResponse.json(row) : notFound()
28
+ }
@@ -0,0 +1,110 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { requireAction } from '../../../../../permissions'
4
+ import { applyFieldPermissions, redactFieldsForRead } from '../../../../../content-types'
5
+ import { getCollection } from '../../../../../collections/registry'
6
+ import { deleteRow, findInvalidRelation, findRow, isForeignKeyViolation, isUniqueViolation, updateRow } from '../../../../../collections/query'
7
+ import { resolveJoinFields } from '../../../../../collections/join'
8
+ import { parseCollectionBody, type ParseMode } from '../../../../../collections/validation'
9
+ import { entryRequiresApproval, fireWebhook, transitions, updateWorkflowValues } from '../../../../../collections/workflow'
10
+ import { applySlugFallbacks } from '../../../../../collections/values'
11
+ import { getLocaleConfig } from '../../../../../locale-settings'
12
+
13
+ const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
14
+ const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
15
+ const patchSchema = z.object({ published: z.boolean() })
16
+
17
+ export async function GET(_request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
18
+ const { collection, id } = await params
19
+ const config = getCollection(collection)
20
+ if (!config) return notFound()
21
+ const result = await requireAction(config.slug, 'read')
22
+ if (!result) return unauthorized()
23
+ const row = await findRow(config.slug, id)
24
+ if (!row) return notFound()
25
+ return NextResponse.json(redactFieldsForRead(config.slug, await resolveJoinFields(config, row), result.allowedFields(config.slug)))
26
+ }
27
+
28
+ export async function PUT(request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
29
+ const { collection, id } = await params
30
+ const config = getCollection(collection)
31
+ if (!config) return notFound()
32
+ const result = await requireAction(config.slug, 'update')
33
+ if (!result) return unauthorized()
34
+
35
+ const existing = await findRow(config.slug, id)
36
+ if (!existing) return notFound()
37
+
38
+ const body = await request.json()
39
+ const workflow = config.workflow
40
+ const wantsPublished = workflow?.publish ? Boolean(body.published) : null
41
+ const mode: ParseMode = workflow?.publish ? (wantsPublished ? 'live' : 'draft') : 'live'
42
+ const parsed = parseCollectionBody(config, body, mode, result.allowedFields(config.slug), await getLocaleConfig())
43
+ if (!parsed.ok) return NextResponse.json({ error: parsed.error }, { status: 400 })
44
+ const data = applySlugFallbacks(config, applyFieldPermissions(config.slug, parsed.data, result.allowedFields(config.slug), existing), id)
45
+
46
+ const relationError = await findInvalidRelation(config, data)
47
+ if (relationError) return NextResponse.json({ error: relationError }, { status: 400 })
48
+
49
+ if (wantsPublished && !result.can(config.slug, 'publish')) return unauthorized()
50
+ if (wantsPublished && await entryRequiresApproval(config) && existing.reviewStatus !== 'approved') return NextResponse.json({ error: 'This item has not been approved yet — publishing requires approval.' }, { status: 403 })
51
+
52
+ try {
53
+ const row = await updateRow(config.slug, id, {
54
+ ...data,
55
+ ...(config.touchUpdatedAt ? { updatedAt: new Date() } : {}),
56
+ ...updateWorkflowValues(config, result.session.user.id, wantsPublished),
57
+ ...(workflow?.audit && typeof body.ownerId === 'string' ? { ownerId: body.ownerId || null } : {}),
58
+ })
59
+ if (row) fireWebhook(config, 'updated', row)
60
+ return row ? NextResponse.json(row) : notFound()
61
+ } catch (error) {
62
+ if (isUniqueViolation(error)) return NextResponse.json({ error: 'A record with one of those unique values already exists.' }, { status: 409 })
63
+ throw error
64
+ }
65
+ }
66
+
67
+ export async function PATCH(request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
68
+ const { collection, id } = await params
69
+ const config = getCollection(collection)
70
+ if (!config?.workflow?.publish) return notFound()
71
+ const result = await requireAction(config.slug, 'publish')
72
+ if (!result) return unauthorized()
73
+ const parsed = patchSchema.safeParse(await request.json())
74
+ if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
75
+ const existing = await findRow(config.slug, id)
76
+ if (!existing) return notFound()
77
+ if (parsed.data.published && await entryRequiresApproval(config) && existing.reviewStatus !== 'approved') return NextResponse.json({ error: 'This item has not been approved yet — publishing requires approval.' }, { status: 403 })
78
+ const row = await updateRow(config.slug, id, {
79
+ ...updateWorkflowValues(config, result.session.user.id, parsed.data.published),
80
+ ...(config.touchUpdatedAt ? { updatedAt: new Date() } : {}),
81
+ })
82
+ if (row) fireWebhook(config, parsed.data.published ? 'published' : 'unpublished', row)
83
+ return row ? NextResponse.json(row) : notFound()
84
+ }
85
+
86
+ export async function DELETE(request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
87
+ const { collection, id } = await params
88
+ const config = getCollection(collection)
89
+ if (!config) return notFound()
90
+ const result = await requireAction(config.slug, 'delete')
91
+ if (!result) return unauthorized()
92
+
93
+ const permanent = new URL(request.url).searchParams.get('permanent') === '1'
94
+ if (config.workflow?.trash && !permanent) {
95
+ const row = await updateRow(config.slug, id, {
96
+ ...transitions.trash(result.session.user.id),
97
+ ...(config.touchUpdatedAt ? { updatedAt: new Date() } : {}),
98
+ })
99
+ if (row) fireWebhook(config, 'deleted', row)
100
+ return row ? NextResponse.json(row) : notFound()
101
+ }
102
+
103
+ try {
104
+ const deleted = await deleteRow(config.slug, id)
105
+ return deleted ? NextResponse.json({ ok: true }) : notFound()
106
+ } catch (error) {
107
+ if (isForeignKeyViolation(error)) return NextResponse.json({ error: 'This record is still referenced by other records and cannot be deleted.' }, { status: 409 })
108
+ throw error
109
+ }
110
+ }
@@ -0,0 +1,42 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { requireAction } from '../../../../../../permissions'
4
+ import { getCollection } from '../../../../../../collections/registry'
5
+ import { findRow, updateRow } from '../../../../../../collections/query'
6
+ import { parseCollectionBody } from '../../../../../../collections/validation'
7
+ import { entryRequiresApproval, fireWebhook, scheduleValues } from '../../../../../../collections/workflow'
8
+ import { getLocaleConfig } from '../../../../../../locale-settings'
9
+
10
+ const schema = z.object({ scheduledAt: z.string().datetime() })
11
+ const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
12
+ const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
13
+
14
+ export async function PATCH(request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
15
+ const { collection, id } = await params
16
+ const config = getCollection(collection)
17
+ if (!config?.workflow?.scheduling) return notFound()
18
+ const result = await requireAction(config.slug, 'publish')
19
+ if (!result) return unauthorized()
20
+
21
+ const parsed = schema.safeParse(await request.json())
22
+ if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
23
+
24
+ const existing = await findRow(config.slug, id)
25
+ if (!existing) return notFound()
26
+
27
+ // Reproduces the hand-written `mustBeComplete = published || Boolean(scheduledAt)` rule:
28
+ // a scheduled row must already satisfy full 'live' validation. The editor PUTs its edits
29
+ // immediately before calling this, so validating the stored row == validating the payload.
30
+ const complete = parseCollectionBody(config, existing, 'live', result.allowedFields(config.slug), await getLocaleConfig())
31
+ if (!complete.ok) return NextResponse.json({ error: `Complete the required fields before scheduling — ${complete.error}` }, { status: 400 })
32
+
33
+ if (await entryRequiresApproval(config) && existing.reviewStatus !== 'approved')
34
+ return NextResponse.json({ error: 'This item has not been approved yet — publishing and scheduling require approval.' }, { status: 403 })
35
+
36
+ const row = await updateRow(config.slug, id, {
37
+ ...scheduleValues(config, new Date(parsed.data.scheduledAt), result.session.user.id),
38
+ ...(config.touchUpdatedAt ? { updatedAt: new Date() } : {}),
39
+ })
40
+ if (row) fireWebhook(config, 'updated', row)
41
+ return row ? NextResponse.json(row) : notFound()
42
+ }
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { requireAction } from '../../../../../../permissions'
4
+ import { getCollection } from '../../../../../../collections/registry'
5
+ import { updateRow } from '../../../../../../collections/query'
6
+ import { fireWebhook, transitions } from '../../../../../../collections/workflow'
7
+
8
+ const schema = z.object({ reviewerId: z.string().min(1) })
9
+ const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
10
+ const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
11
+
12
+ export async function PATCH(request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
13
+ const { collection, id } = await params
14
+ const config = getCollection(collection)
15
+ if (!config?.workflow?.review) return notFound()
16
+ const result = await requireAction(config.slug, 'submit_review')
17
+ if (!result) return unauthorized()
18
+ const parsed = schema.safeParse(await request.json())
19
+ if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
20
+ const row = await updateRow(config.slug, id, transitions.submitReview(parsed.data.reviewerId, result.session.user.id))
21
+ if (row) fireWebhook(config, 'updated', row)
22
+ return row ? NextResponse.json(row) : notFound()
23
+ }
@@ -0,0 +1,22 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { requireAction } from '../../../../../../permissions'
3
+ import { getCollection } from '../../../../../../collections/registry'
4
+ import { updateRow } from '../../../../../../collections/query'
5
+ import { fireWebhook, unscheduleValues } from '../../../../../../collections/workflow'
6
+
7
+ const unauthorized = () => NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
8
+ const notFound = () => NextResponse.json({ error: 'Not found' }, { status: 404 })
9
+
10
+ export async function PATCH(_request: Request, { params }: { params: Promise<{ collection: string; id: string }> }) {
11
+ const { collection, id } = await params
12
+ const config = getCollection(collection)
13
+ if (!config?.workflow?.scheduling) return notFound()
14
+ const result = await requireAction(config.slug, 'publish')
15
+ if (!result) return unauthorized()
16
+ const row = await updateRow(config.slug, id, {
17
+ ...unscheduleValues(config, result.session.user.id),
18
+ ...(config.touchUpdatedAt ? { updatedAt: new Date() } : {}),
19
+ })
20
+ if (row) fireWebhook(config, 'updated', row)
21
+ return row ? NextResponse.json(row) : notFound()
22
+ }
@@ -0,0 +1,61 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { requireAction } from '../../../../../permissions'
4
+ import { getCollection } from '../../../../../collections/registry'
5
+ import { collectionColumn, deleteRows, idColumn, isForeignKeyViolation, updateRows, updateWhere } from '../../../../../collections/query'
6
+ import { entryRequiresApproval, transitions } from '../../../../../collections/workflow'
7
+ import { and, eq, inArray } from 'drizzle-orm'
8
+
9
+ const bulkSchema = z.object({
10
+ ids: z.array(z.string().min(1).max(200)).min(1).max(200),
11
+ action: z.enum(['delete', 'publish', 'unpublish', 'trash', 'restore']),
12
+ })
13
+
14
+ export async function PATCH(request: Request, { params }: { params: Promise<{ collection: string }> }) {
15
+ const { collection } = await params
16
+ const config = getCollection(collection)
17
+ if (!config) return NextResponse.json({ error: 'Not found' }, { status: 404 })
18
+
19
+ const parsed = bulkSchema.safeParse(await request.json())
20
+ if (!parsed.success) return NextResponse.json({ error: 'Invalid request.' }, { status: 400 })
21
+
22
+ const { ids, action } = parsed.data
23
+ const workflow = config.workflow
24
+ const supported = action === 'delete'
25
+ ? true
26
+ : action === 'publish' || action === 'unpublish'
27
+ ? Boolean(workflow?.publish)
28
+ : Boolean(workflow?.trash)
29
+ if (!supported) return NextResponse.json({ error: 'That action is not available for this collection.' }, { status: 400 })
30
+
31
+ const result = await requireAction(config.slug, action === 'publish' || action === 'unpublish' ? 'publish' : 'delete')
32
+ if (!result) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
33
+
34
+ if (action === 'delete') {
35
+ try {
36
+ const count = await deleteRows(config.slug, ids)
37
+ return NextResponse.json({ ok: true, count })
38
+ } catch (error) {
39
+ if (isForeignKeyViolation(error)) return NextResponse.json({ error: 'Some of these records are still referenced by other records and cannot be deleted.' }, { status: 409 })
40
+ throw error
41
+ }
42
+ }
43
+
44
+ if (action === 'publish') {
45
+ const reviewStatus = collectionColumn(config.slug, 'reviewStatus')
46
+ const onlyApproved = await entryRequiresApproval(config)
47
+ const rows = await updateWhere(
48
+ config.slug,
49
+ onlyApproved && reviewStatus ? and(inArray(idColumn(config.slug), ids), eq(reviewStatus, 'approved'))! : inArray(idColumn(config.slug), ids),
50
+ { published: true, publishedAt: new Date(), ...(workflow?.scheduling ? { scheduledAt: null } : {}), ...(workflow?.audit ? { updatedBy: result.session.user.id } : {}) },
51
+ )
52
+ return NextResponse.json({ ok: true, count: rows.length, skipped: ids.length - rows.length })
53
+ }
54
+
55
+ const set = action === 'unpublish'
56
+ ? { published: false, ...(workflow?.audit ? { updatedBy: result.session.user.id } : {}) }
57
+ : action === 'trash'
58
+ ? transitions.trash(result.session.user.id)
59
+ : transitions.restore()
60
+ return NextResponse.json({ ok: true, count: await updateRows(config.slug, ids, set) })
61
+ }