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.
- package/README.md +186 -0
- package/drizzle.kilo.config.ts +17 -0
- package/package.json +97 -0
- package/src/admin/AiSettingsEditor.tsx +48 -0
- package/src/admin/AiWritingAssistant.tsx +159 -0
- package/src/admin/ApiTokensEditor.tsx +69 -0
- package/src/admin/ArrayField.tsx +118 -0
- package/src/admin/BlocksField.tsx +131 -0
- package/src/admin/Button.tsx +31 -0
- package/src/admin/CloseButton.tsx +18 -0
- package/src/admin/CmsLocalesProvider.tsx +19 -0
- package/src/admin/CmsSidebar.tsx +147 -0
- package/src/admin/CmsSiteSettingsProvider.tsx +30 -0
- package/src/admin/CmsThemeProvider.tsx +100 -0
- package/src/admin/CollectionListEditor.tsx +278 -0
- package/src/admin/CollectionRecordEditor.tsx +420 -0
- package/src/admin/ContentEditorForm.tsx +21 -0
- package/src/admin/ContentViewSettings.tsx +55 -0
- package/src/admin/DashboardBuilder.tsx +284 -0
- package/src/admin/DragHandle.tsx +3 -0
- package/src/admin/DynamicField.tsx +517 -0
- package/src/admin/EditorHeader.tsx +13 -0
- package/src/admin/EditorSkeleton.tsx +27 -0
- package/src/admin/EmailSettingsEditor.tsx +80 -0
- package/src/admin/Field.tsx +215 -0
- package/src/admin/FilterBuilder.tsx +62 -0
- package/src/admin/GroupField.tsx +61 -0
- package/src/admin/ImageUploader.tsx +198 -0
- package/src/admin/ListingSettingsModal.tsx +68 -0
- package/src/admin/ListingView.tsx +123 -0
- package/src/admin/LocalesEditor.tsx +86 -0
- package/src/admin/LocalizedField.tsx +46 -0
- package/src/admin/MapPicker.tsx +53 -0
- package/src/admin/MediaLibrary.tsx +483 -0
- package/src/admin/MediaUploader.tsx +55 -0
- package/src/admin/Portal.tsx +10 -0
- package/src/admin/PublishingPanel.tsx +102 -0
- package/src/admin/RecordMeta.tsx +24 -0
- package/src/admin/ReviewQueueEditor.tsx +103 -0
- package/src/admin/RichTextEditor.tsx +49 -0
- package/src/admin/RoleEditor.tsx +121 -0
- package/src/admin/RolesEditor.tsx +42 -0
- package/src/admin/RowActionsMenu.tsx +64 -0
- package/src/admin/ScheduledEditor.tsx +155 -0
- package/src/admin/SeoSettingsEditor.tsx +53 -0
- package/src/admin/SingleRecordEditor.tsx +215 -0
- package/src/admin/SiteSettingsEditor.tsx +96 -0
- package/src/admin/Toast.tsx +45 -0
- package/src/admin/UserEditor.tsx +64 -0
- package/src/admin/UsersEditor.tsx +37 -0
- package/src/admin/WebhooksEditor.tsx +115 -0
- package/src/admin/admin.css +6637 -0
- package/src/admin/globals.css +14 -0
- package/src/admin/pages/admin-index.tsx +5 -0
- package/src/admin/pages/ai-settings.tsx +9 -0
- package/src/admin/pages/api-tokens.tsx +10 -0
- package/src/admin/pages/collection-list.tsx +22 -0
- package/src/admin/pages/collection-record.tsx +31 -0
- package/src/admin/pages/dashboard.tsx +10 -0
- package/src/admin/pages/email-settings.tsx +10 -0
- package/src/admin/pages/layout.tsx +24 -0
- package/src/admin/pages/locales-settings.tsx +10 -0
- package/src/admin/pages/login.tsx +25 -0
- package/src/admin/pages/media.tsx +9 -0
- package/src/admin/pages/review-queue.tsx +20 -0
- package/src/admin/pages/role-edit.tsx +11 -0
- package/src/admin/pages/roles.tsx +10 -0
- package/src/admin/pages/scheduled.tsx +17 -0
- package/src/admin/pages/seo-settings.tsx +9 -0
- package/src/admin/pages/setup-admin.tsx +76 -0
- package/src/admin/pages/single-record.tsx +24 -0
- package/src/admin/pages/site-settings.tsx +9 -0
- package/src/admin/pages/user-edit.tsx +11 -0
- package/src/admin/pages/user-new.tsx +10 -0
- package/src/admin/pages/users.tsx +10 -0
- package/src/admin/pages/webhooks-settings.tsx +10 -0
- package/src/admin/useKeyedRows.ts +35 -0
- package/src/ai-config.ts +35 -0
- package/src/ai-secrets.ts +17 -0
- package/src/ai-types.ts +10 -0
- package/src/ai-writer.ts +84 -0
- package/src/api-tokens.ts +25 -0
- package/src/auth-client.ts +5 -0
- package/src/auth.ts +33 -0
- package/src/calendar-grid.ts +12 -0
- package/src/cli/index.mjs +149 -0
- package/src/cms-appearance.ts +55 -0
- package/src/collections/content-view.ts +69 -0
- package/src/collections/define.ts +17 -0
- package/src/collections/filters.ts +92 -0
- package/src/collections/index.ts +11 -0
- package/src/collections/join.ts +64 -0
- package/src/collections/locale.ts +22 -0
- package/src/collections/query.ts +160 -0
- package/src/collections/registry.ts +88 -0
- package/src/collections/schedule.ts +12 -0
- package/src/collections/server.ts +12 -0
- package/src/collections/single.ts +26 -0
- package/src/collections/table.ts +14 -0
- package/src/collections/types.ts +336 -0
- package/src/collections/validation.ts +217 -0
- package/src/collections/values.ts +181 -0
- package/src/collections/workflow.ts +90 -0
- package/src/config.ts +34 -0
- package/src/content-types.ts +218 -0
- package/src/dashboard.ts +39 -0
- package/src/db.ts +50 -0
- package/src/email.ts +39 -0
- package/src/format.ts +4 -0
- package/src/listQuery.ts +23 -0
- package/src/locale-settings.ts +23 -0
- package/src/media-storage.ts +19 -0
- package/src/permissions.ts +37 -0
- package/src/publishing-workflow-db.ts +11 -0
- package/src/publishing-workflow.ts +20 -0
- package/src/richtext.ts +5 -0
- package/src/routes/admin/ai/settings/route.ts +20 -0
- package/src/routes/admin/ai/write/route.ts +21 -0
- package/src/routes/admin/api-tokens/[id]/route.ts +12 -0
- package/src/routes/admin/api-tokens/route.ts +26 -0
- package/src/routes/admin/collections/[collection]/[id]/approve/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/[id]/duplicate/route.ts +36 -0
- package/src/routes/admin/collections/[collection]/[id]/restore/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/[id]/review/route.ts +28 -0
- package/src/routes/admin/collections/[collection]/[id]/route.ts +110 -0
- package/src/routes/admin/collections/[collection]/[id]/schedule/route.ts +42 -0
- package/src/routes/admin/collections/[collection]/[id]/submit-review/route.ts +23 -0
- package/src/routes/admin/collections/[collection]/[id]/unschedule/route.ts +22 -0
- package/src/routes/admin/collections/[collection]/bulk/route.ts +61 -0
- package/src/routes/admin/collections/[collection]/options/route.ts +38 -0
- package/src/routes/admin/collections/[collection]/reorder/route.ts +19 -0
- package/src/routes/admin/collections/[collection]/route.ts +88 -0
- package/src/routes/admin/collections/route.ts +13 -0
- package/src/routes/admin/content-views/[type]/route.ts +25 -0
- package/src/routes/admin/dashboard/data/route.ts +39 -0
- package/src/routes/admin/dashboard/route.ts +30 -0
- package/src/routes/admin/email-settings/route.ts +49 -0
- package/src/routes/admin/email-settings/test/route.ts +18 -0
- package/src/routes/admin/locales/[code]/route.ts +38 -0
- package/src/routes/admin/locales/reorder/route.ts +18 -0
- package/src/routes/admin/locales/route.ts +46 -0
- package/src/routes/admin/media/[id]/restore/route.ts +13 -0
- package/src/routes/admin/media/[id]/route.ts +56 -0
- package/src/routes/admin/media/bulk/route.ts +46 -0
- package/src/routes/admin/media/folders/[id]/route.ts +34 -0
- package/src/routes/admin/media/route.ts +41 -0
- package/src/routes/admin/media/stats/route.ts +14 -0
- package/src/routes/admin/roles/[id]/route.ts +43 -0
- package/src/routes/admin/roles/route.ts +34 -0
- package/src/routes/admin/settings/route.ts +45 -0
- package/src/routes/admin/single/[type]/route.ts +54 -0
- package/src/routes/admin/singles/route.ts +15 -0
- package/src/routes/admin/upload/route.ts +55 -0
- package/src/routes/admin/users/[id]/route.ts +24 -0
- package/src/routes/admin/users/route.ts +24 -0
- package/src/routes/admin/webhooks/[id]/route.ts +38 -0
- package/src/routes/admin/webhooks/route.ts +26 -0
- package/src/routes/cron/publish-scheduled.ts +37 -0
- package/src/routes/setup/admin.ts +19 -0
- package/src/schema/admin-ui.ts +18 -0
- package/src/schema/auth.ts +49 -0
- package/src/schema/developer-tools.ts +44 -0
- package/src/schema/helpers.ts +35 -0
- package/src/schema/index.ts +33 -0
- package/src/schema/locales.ts +13 -0
- package/src/schema/media.ts +22 -0
- package/src/schema/rbac.ts +11 -0
- package/src/schema/settings.ts +28 -0
- package/src/secrets.ts +26 -0
- package/src/seo.ts +69 -0
- package/src/storage/index.ts +20 -0
- package/src/storage/local.ts +27 -0
- package/src/storage/s3-compatible.ts +31 -0
- package/src/storage/types.ts +8 -0
- package/src/storage/vercel-blob.ts +17 -0
- package/src/useBodyScrollLock.ts +12 -0
- package/src/useLocalStorageState.ts +26 -0
- package/src/webhook-events.ts +12 -0
- package/src/webhooks.ts +30 -0
package/src/format.ts
ADDED
package/src/listQuery.ts
ADDED
|
@@ -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
|
+
}
|
package/src/richtext.ts
ADDED
|
@@ -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
|
+
}
|