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

@@ -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>
)