Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 41 additions & 3 deletions backend/app/routers/documents.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,9 +213,13 @@ def get_document(document_id: int, current_user: User = Depends(get_current_user


@router.put('/{document_id}', response_model=DocumentResponse)
def update_document(
async def update_document(
document_id: int,
payload: DocumentUpdate,
request: Request,
file: UploadFile | None = File(default=None),
title: str | None = Form(default=None),
description: str | None = Form(default=None),
summary: str | None = Form(default=None),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
Expand All @@ -226,7 +230,41 @@ def update_document(
if current_user.role != 'admin' and document.created_by != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Not authorized')

updates = payload.model_dump(exclude_unset=True)
content_type = request.headers.get('content-type', '')

if content_type.startswith('multipart/form-data'):
updates: dict[str, str] = {}

if title is not None:
cleaned_title = title.strip()
if not cleaned_title:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail='Title cannot be empty')
updates['title'] = cleaned_title

if description is not None:
updates['description'] = description.strip()
updates['source_type'] = 'typed'
updates['file_name'] = None
updates['file_type'] = None

if file:
text_content, file_name, file_type = await extract_text_from_upload(file)
updates['description'] = text_content
updates['source_type'] = 'uploaded'
updates['file_name'] = file_name
updates['file_type'] = file_type

if summary is not None:
cleaned_summary = summary.strip()
updates['summary'] = cleaned_summary

else:
try:
payload = DocumentUpdate.model_validate(await request.json())
except ValidationError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail='Invalid JSON payload') from exc
updates = payload.model_dump(exclude_unset=True)

if 'summary' in updates:
updates['summary_embedding'] = json.dumps(generate_summary_embedding(updates['summary']))

Expand Down
21 changes: 21 additions & 0 deletions frontend/src/api/documents.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,27 @@ export const createDocumentApi = async (payload) => {
}

export const updateDocumentApi = async (id, payload) => {
if (payload.file) {
const formData = new FormData()
if (payload.title !== undefined) {
formData.append('title', payload.title)
}
if (payload.description !== undefined) {
formData.append('description', payload.description || '')
}
if (payload.summary !== undefined) {
formData.append('summary', payload.summary)
}
formData.append('file', payload.file)

const { data } = await api.put(`/documents/${id}`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
return data
}

const { data } = await api.put(`/documents/${id}`, payload)
return data
}
Expand Down
37 changes: 18 additions & 19 deletions frontend/src/components/DocumentForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const DocumentForm = ({ initialValues, onSubmit, onCancel, onRefreshSummary }) =
}

const handleRefreshSummary = async () => {
if (!onRefreshSummary) return
setSummaryError('')

const sourceDescription = selectedFile ? '' : form.description
Expand Down Expand Up @@ -85,23 +86,21 @@ const DocumentForm = ({ initialValues, onSubmit, onCancel, onRefreshSummary }) =
<input className={inputClass} placeholder="Document title" value={form.title} onChange={(e) => setForm((prev) => ({ ...prev, title: e.target.value }))} required />
<textarea className={inputClass} rows="3" placeholder="Description" value={form.description} onChange={(e) => setForm((prev) => ({ ...prev, description: e.target.value }))} required={!selectedFile} disabled={!!selectedFile} />

{!isEditing && (
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">OR upload a file</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">Supported: .txt, .pdf, .docx</p>
<label className="mt-3 inline-flex cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-500">
Browse File
<input
type="file"
accept=".txt,.pdf,.docx"
className="hidden"
onChange={(e) => validateAndSetFile(e.target.files?.[0])}
/>
</label>
{selectedFile && <p className="mt-2 text-xs text-emerald-600 dark:text-emerald-300">Selected: {selectedFile.name}</p>}
{fileError && <p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{fileError}</p>}
</div>
)}
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 p-4 dark:border-slate-700 dark:bg-slate-800/50">
<p className="text-sm font-medium text-slate-700 dark:text-slate-200">{isEditing ? 'Upload a replacement file' : 'OR upload a file'}</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">Supported: .txt, .pdf, .docx</p>
<label className="mt-3 inline-flex cursor-pointer rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-500">
Browse File
<input
type="file"
accept=".txt,.pdf,.docx"
className="hidden"
onChange={(e) => validateAndSetFile(e.target.files?.[0])}
/>
</label>
{selectedFile && <p className="mt-2 text-xs text-emerald-600 dark:text-emerald-300">Selected: {selectedFile.name}</p>}
{fileError && <p className="mt-2 text-xs text-rose-600 dark:text-rose-300">{fileError}</p>}
</div>

<textarea
className={inputClass}
Expand All @@ -116,14 +115,14 @@ const DocumentForm = ({ initialValues, onSubmit, onCancel, onRefreshSummary }) =
className="rounded-lg border border-slate-300 px-3 py-2 text-xs font-medium transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-slate-700 dark:hover:bg-slate-800"
type="button"
onClick={handleRefreshSummary}
disabled={refreshingSummary || !!selectedFile}
disabled={!onRefreshSummary || refreshingSummary || !!selectedFile}
>
{refreshingSummary ? 'Refreshing...' : 'Refresh summary'}
</button>
<p className="text-xs text-slate-500 dark:text-slate-400">Generate summary from your current title + description without saving.</p>
</div>
{summaryError && <p className="text-xs text-rose-600 dark:text-rose-300">{summaryError}</p>}
{!isEditing && <p className="text-xs text-slate-500 dark:text-slate-400">You can type a description or upload a supported document file.</p>}
<p className="text-xs text-slate-500 dark:text-slate-400">You can type a description or upload a supported document file.</p>
{form.summary_embedding && (
<div className="rounded-xl border border-slate-200 bg-slate-50 p-3 dark:border-slate-700 dark:bg-slate-800/50">
<p className="mb-2 text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">Vector Embeddings</p>
Expand Down
13 changes: 8 additions & 5 deletions frontend/src/pages/AdminDashboard.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { listDocumentsApi, updateDocumentApi, deleteDocumentApi } from '../api/documents'
import { createDocumentApi, listDocumentsApi, previewDocumentSummaryApi, updateDocumentApi, deleteDocumentApi } from '../api/documents'
import { listUsersApi } from '../api/users'
import DocumentForm from '../components/DocumentForm'
import Layout from '../components/Layout'
Expand Down Expand Up @@ -28,9 +28,12 @@ const AdminDashboard = () => {
}, [])

const handleSubmit = async (payload) => {
if (!editing) return
await updateDocumentApi(editing.id, payload)
setEditing(null)
if (editing) {
await updateDocumentApi(editing.id, payload)
setEditing(null)
} else {
await createDocumentApi(payload)
}
await loadData()
}

Expand All @@ -45,7 +48,7 @@ const AdminDashboard = () => {
subtitle="User and document oversight"
sidebarItems={[{ to: '/admin', label: 'Admin Overview' }]}
>
{editing && <DocumentForm initialValues={editing} onSubmit={handleSubmit} onCancel={() => setEditing(null)} />}
<DocumentForm initialValues={editing} onSubmit={handleSubmit} onCancel={editing ? () => setEditing(null) : null} onRefreshSummary={previewDocumentSummaryApi} />
{error && <p className="mb-4 rounded-lg border border-rose-200 bg-rose-50 px-3 py-2 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/40 dark:text-rose-300">{error}</p>}

<div className="grid gap-4 xl:grid-cols-2">
Expand Down