feat: add new page and migration scripts for access rules and collections

- Introduced new page component for library topics with type checks.
- Added migration scripts to update access rules for various collections including badges, curriculum versions, and themes.
- Implemented PocketBase integration for managing collection access rules dynamically.
- Ensured proper type validation for page props and metadata generation functions.
This commit is contained in:
RaymondVerhoef
2026-05-23 22:26:40 +02:00
parent 14286d6cb1
commit 8684ffa35b
109 changed files with 2065 additions and 114 deletions

View File

@@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'
import { useParams } from 'next/navigation'
import Link from 'next/link'
import { pb } from '@/lib/pocketbase'
import { postGenerateAll } from '@/lib/services'
import { TopicEditCard } from '@/components/admin/TopicEditCard'
import { Button } from '@/components/ui/Button'
import { StatusBadge } from '@/components/ui/StatusBadge'
@@ -17,6 +18,7 @@ export default function ThemeDetailPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [publishingAll, setPublishingAll] = useState(false)
const [approveToast, setApproveToast] = useState<string | null>(null)
async function load() {
setLoading(true)
@@ -43,17 +45,24 @@ export default function ThemeDetailPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [themeId])
async function handlePublishAll() {
async function handleApproveBatch() {
setPublishingAll(true)
setApproveToast(null)
try {
// Publish all draft topics
await Promise.all(
topics
.filter((t) => t.status === 'draft')
.map((t) => pb.collection('topics').update(t.id, { status: 'published' })),
)
// Publish the theme
await pb.collection('themes').update(themeId, { status: 'published' })
// Trigger generation
const { queued } = await postGenerateAll(themeId)
setApproveToast(`Approved. Generation queued for ${queued} micro-learnings.`)
void load()
} catch {
// refresh anyway
} catch (err) {
setApproveToast(err instanceof Error ? err.message : 'Approval failed')
void load()
} finally {
setPublishingAll(false)
@@ -134,16 +143,21 @@ export default function ThemeDetailPage() {
<span style={{ fontSize: 'var(--t-body)', color: 'var(--fg-muted)' }}>
{publishedCount} published · {draftCount} draft
</span>
{draftCount > 0 && (
{theme.status !== 'published' && (
<Button
variant="primary"
size="sm"
onClick={() => void handlePublishAll()}
onClick={() => void handleApproveBatch()}
loading={publishingAll}
>
Publish all drafts ({draftCount})
Approve batch{draftCount > 0 ? ` (${draftCount} draft${draftCount !== 1 ? 's' : ''})` : ''}
</Button>
)}
{approveToast && (
<span style={{ fontSize: 'var(--t-small)', color: 'var(--teal)', fontWeight: 500 }}>
{approveToast}
</span>
)}
</div>
{/* Topics list */}

View File

@@ -38,19 +38,26 @@ export function DocumentUpload({ onSuccess }: DocumentUploadProps) {
try {
// Create record in PocketBase
const format = ext.slice(1) as 'pdf' | 'md' | 'txt'
const fd = new FormData()
fd.append('file', file)
fd.append('filename', file.name)
fd.append('format', ext.slice(1) as 'pdf' | 'md' | 'txt')
fd.append('format', format)
fd.append('status', 'processing')
const doc = await pb.collection('source_documents').create<{ id: string }>(fd)
const doc = await pb.collection('source_documents').create<{ id: string; file: string }>(fd)
// Build PocketBase file download URL
const pbUrl = process.env.NEXT_PUBLIC_POCKETBASE_URL ?? 'http://localhost:8090'
const fileUrl = `${pbUrl}/api/files/source_documents/${doc.id}/${doc.file}`
// Kick off ingestion
const ingestFd = new FormData()
ingestFd.append('documentId', doc.id)
const { jobId: jId } = await postIngest(ingestFd)
const { jobId: jId } = await postIngest({
documentId: doc.id,
filename: file.name,
format,
fileUrl,
})
setJobId(jId)
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Upload failed')

View File

@@ -4,6 +4,7 @@ import Link from 'next/link'
import type { Theme } from '@/types'
import { StatusBadge } from '@/components/ui/StatusBadge'
import { Button } from '@/components/ui/Button'
import { pb } from '@/lib/pocketbase'
import { postGenerateAll } from '@/lib/services'
interface ThemeCardProps {
@@ -12,24 +13,57 @@ interface ThemeCardProps {
}
export function ThemeCard({ theme, onUpdate }: ThemeCardProps) {
const [generating, setGenerating] = useState(false)
const [approving, setApproving] = useState(false)
const [rejecting, setRejecting] = useState(false)
const [toast, setToast] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [queued, setQueued] = useState<number | null>(null)
async function handleGenerate() {
setGenerating(true)
async function handleApprove() {
setApproving(true)
setError(null)
setToast(null)
try {
const { queued: q } = await postGenerateAll(theme.id)
setQueued(q)
// 1. Fetch all topics in this theme
const topics = await pb.collection('topics').getFullList<{ id: string }>({
filter: `theme="${theme.id}"`,
fields: 'id',
})
// 2. Publish all topics
await Promise.all(
topics.map((t) => pb.collection('topics').update(t.id, { status: 'published' }))
)
// 3. Publish the theme
await pb.collection('themes').update(theme.id, { status: 'published' })
// 4. Trigger generation service
const { queued } = await postGenerateAll(theme.id)
setToast(`Generation queued for ${queued} micro-learnings`)
onUpdate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Generation failed')
setError(err instanceof Error ? err.message : 'Approval failed')
} finally {
setGenerating(false)
setApproving(false)
}
}
async function handleReject() {
setRejecting(true)
setError(null)
try {
await pb.collection('themes').update(theme.id, { status: 'rejected' })
onUpdate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Rejection failed')
} finally {
setRejecting(false)
}
}
const isPublished = theme.status === 'published'
const isRejected = theme.status === 'rejected'
return (
<div
style={{
@@ -40,28 +74,15 @@ export function ThemeCard({ theme, onUpdate }: ThemeCardProps) {
display: 'flex',
flexDirection: 'column',
gap: 'var(--s-3)',
opacity: isRejected ? 0.6 : 1,
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 'var(--s-3)' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<h3
style={{
margin: '0 0 var(--s-1)',
fontSize: 'var(--t-body)',
fontWeight: 700,
color: 'var(--fg)',
}}
>
<h3 style={{ margin: '0 0 var(--s-1)', fontSize: 'var(--t-body)', fontWeight: 700, color: 'var(--fg)' }}>
{theme.title}
</h3>
<p
style={{
margin: 0,
fontSize: 'var(--t-small)',
color: 'var(--fg-muted)',
lineHeight: 1.5,
}}
>
<p style={{ margin: 0, fontSize: 'var(--t-small)', color: 'var(--fg-muted)', lineHeight: 1.5 }}>
{theme.description}
</p>
</div>
@@ -80,49 +101,34 @@ export function ThemeCard({ theme, onUpdate }: ThemeCardProps) {
{theme.source_documents.length} source doc{theme.source_documents.length !== 1 ? 's' : ''}
</div>
{queued !== null && (
<div
style={{
background: 'var(--success)',
color: 'var(--teal-900)',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-2) var(--s-3)',
fontSize: 'var(--t-small)',
fontWeight: 500,
}}
>
{queued} micro-learnings queued for generation
{toast && (
<div style={{ background: 'var(--success)', color: 'var(--teal-900)', borderRadius: 'var(--r-sm)', padding: 'var(--s-2) var(--s-3)', fontSize: 'var(--t-small)', fontWeight: 500 }}>
{toast}
</div>
)}
{error && (
<div
style={{
background: '#f8d7da',
color: '#c0392b',
borderRadius: 'var(--r-sm)',
padding: 'var(--s-2) var(--s-3)',
fontSize: 'var(--t-small)',
}}
>
<div style={{ background: '#f8d7da', color: '#c0392b', borderRadius: 'var(--r-sm)', padding: 'var(--s-2) var(--s-3)', fontSize: 'var(--t-small)' }}>
{error}
</div>
)}
<div style={{ display: 'flex', gap: 'var(--s-2)', flexWrap: 'wrap' }}>
<Link href={`/admin/knowledge/${theme.id}`} style={{ textDecoration: 'none' }}>
<Button variant="secondary" size="sm">
View topics
</Button>
</Link>
<Button
variant="primary"
size="sm"
onClick={() => void handleGenerate()}
loading={generating}
>
Generate micro-learnings
</Button>
{!isRejected && (
<Link href={`/admin/knowledge/${theme.id}`} style={{ textDecoration: 'none' }}>
<Button variant="secondary" size="sm">Edit topics</Button>
</Link>
)}
{!isPublished && !isRejected && (
<>
<Button variant="primary" size="sm" onClick={() => void handleApprove()} loading={approving}>
Approve batch
</Button>
<Button variant="danger" size="sm" onClick={() => void handleReject()} loading={rejecting}>
Reject
</Button>
</>
)}
</div>
</div>
)

View File

@@ -19,16 +19,22 @@ async function handleResponse<T>(res: Response): Promise<T> {
return res.json() as Promise<T>
}
export async function postIngest(formData: FormData): Promise<{ jobId: string }> {
export async function postIngest(payload: {
documentId: string
filename: string
format: 'pdf' | 'md' | 'txt'
fileUrl: string
}): Promise<{ jobId: string }> {
const res = await fetch(`${INGESTION_URL}/ingest`, {
method: 'POST',
body: formData,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return handleResponse<{ jobId: string }>(res)
}
export async function getIngestionStatus(jobId: string): Promise<IngestionJobStatus> {
const res = await fetch(`${INGESTION_URL}/ingest/${encodeURIComponent(jobId)}/status`)
const res = await fetch(`${INGESTION_URL}/ingest/status/${encodeURIComponent(jobId)}`)
return handleResponse<IngestionJobStatus>(res)
}