feat: implement document management module
- Backend (FastAPI): - Attachment and AttachmentVersion models with migration - FileStorageService with SHA-256 checksum validation - File type validation (whitelist/blacklist) - Full CRUD API with version control support - Audit trail integration for upload/download/delete - Configurable upload directory and file size limit - Frontend (React + Vite): - AttachmentUpload component with drag & drop - AttachmentList component with download/delete - TaskAttachments combined component - Attachments service for API calls - Testing: - 12 tests for storage service and API endpoints - OpenSpec: - add-document-management change archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
197
frontend/src/components/AttachmentList.tsx
Normal file
197
frontend/src/components/AttachmentList.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { attachmentService, Attachment } from '../services/attachments'
|
||||
|
||||
interface AttachmentListProps {
|
||||
taskId: string
|
||||
onRefresh?: () => void
|
||||
}
|
||||
|
||||
export function AttachmentList({ taskId, onRefresh }: AttachmentListProps) {
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadAttachments()
|
||||
}, [taskId])
|
||||
|
||||
const loadAttachments = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const response = await attachmentService.listAttachments(taskId)
|
||||
setAttachments(response.attachments)
|
||||
} catch (error) {
|
||||
console.error('Failed to load attachments:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownload = async (attachment: Attachment) => {
|
||||
try {
|
||||
await attachmentService.downloadAttachment(attachment.id)
|
||||
} catch (error) {
|
||||
console.error('Failed to download attachment:', error)
|
||||
alert('Failed to download file')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (attachment: Attachment) => {
|
||||
if (!confirm(`Are you sure you want to delete "${attachment.filename}"?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
setDeleting(attachment.id)
|
||||
try {
|
||||
await attachmentService.deleteAttachment(attachment.id)
|
||||
setAttachments(prev => prev.filter(a => a.id !== attachment.id))
|
||||
onRefresh?.()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete attachment:', error)
|
||||
alert('Failed to delete file')
|
||||
} finally {
|
||||
setDeleting(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div style={styles.loading}>Loading attachments...</div>
|
||||
}
|
||||
|
||||
if (attachments.length === 0) {
|
||||
return <div style={styles.empty}>No attachments</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
{attachments.map((attachment) => (
|
||||
<div key={attachment.id} style={styles.item}>
|
||||
<div style={styles.itemInfo}>
|
||||
<span style={styles.icon}>
|
||||
{attachmentService.getFileIcon(attachment.mime_type)}
|
||||
</span>
|
||||
<div style={styles.details}>
|
||||
<div style={styles.filename}>{attachment.filename}</div>
|
||||
<div style={styles.meta}>
|
||||
{attachmentService.formatFileSize(attachment.file_size)}
|
||||
{attachment.current_version > 1 && (
|
||||
<span style={styles.version}>v{attachment.current_version}</span>
|
||||
)}
|
||||
<span style={styles.uploader}>
|
||||
by {attachment.uploader_name || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.actions}>
|
||||
<button
|
||||
style={styles.downloadBtn}
|
||||
onClick={() => handleDownload(attachment)}
|
||||
title="Download"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
style={styles.deleteBtn}
|
||||
onClick={() => handleDelete(attachment)}
|
||||
disabled={deleting === attachment.id}
|
||||
title="Delete"
|
||||
>
|
||||
{deleting === attachment.id ? '...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
},
|
||||
item: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px',
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e9ecef',
|
||||
},
|
||||
itemInfo: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
icon: {
|
||||
fontSize: '24px',
|
||||
},
|
||||
details: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
filename: {
|
||||
fontWeight: 500,
|
||||
fontSize: '14px',
|
||||
color: '#212529',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
},
|
||||
meta: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
marginTop: '2px',
|
||||
},
|
||||
version: {
|
||||
backgroundColor: '#e9ecef',
|
||||
padding: '1px 6px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '11px',
|
||||
},
|
||||
uploader: {
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
actions: {
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
},
|
||||
downloadBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#007bff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
deleteBtn: {
|
||||
padding: '6px 12px',
|
||||
backgroundColor: '#dc3545',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '12px',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
loading: {
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
},
|
||||
empty: {
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
color: '#6c757d',
|
||||
fontSize: '14px',
|
||||
},
|
||||
}
|
||||
|
||||
export default AttachmentList
|
||||
194
frontend/src/components/AttachmentUpload.tsx
Normal file
194
frontend/src/components/AttachmentUpload.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useState, useRef, DragEvent, ChangeEvent } from 'react'
|
||||
import { attachmentService } from '../services/attachments'
|
||||
|
||||
interface AttachmentUploadProps {
|
||||
taskId: string
|
||||
onUploadComplete?: () => void
|
||||
}
|
||||
|
||||
export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = async (e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
if (files.length > 0) {
|
||||
await uploadFiles(files)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files ? Array.from(e.target.files) : []
|
||||
if (files.length > 0) {
|
||||
await uploadFiles(files)
|
||||
}
|
||||
// Reset input
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const uploadFiles = async (files: File[]) => {
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
setUploadProgress(`Uploading ${file.name} (${i + 1}/${files.length})...`)
|
||||
await attachmentService.uploadAttachment(taskId, file)
|
||||
}
|
||||
setUploadProgress(null)
|
||||
onUploadComplete?.()
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload failed:', err)
|
||||
const errorMessage = err instanceof Error ? err.message : 'Upload failed'
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div
|
||||
style={{
|
||||
...styles.dropzone,
|
||||
...(isDragging ? styles.dropzoneActive : {}),
|
||||
...(uploading ? styles.dropzoneDisabled : {}),
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={!uploading ? handleClick : undefined}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
style={styles.hiddenInput}
|
||||
onChange={handleFileSelect}
|
||||
multiple
|
||||
disabled={uploading}
|
||||
/>
|
||||
{uploading ? (
|
||||
<div style={styles.uploading}>
|
||||
<div style={styles.spinner}></div>
|
||||
<span>{uploadProgress}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={styles.content}>
|
||||
<span style={styles.icon}>📎</span>
|
||||
<span style={styles.text}>
|
||||
Drop files here or click to upload
|
||||
</span>
|
||||
<span style={styles.hint}>
|
||||
Maximum file size: 50MB
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div style={styles.error}>{error}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
marginBottom: '16px',
|
||||
},
|
||||
dropzone: {
|
||||
border: '2px dashed #dee2e6',
|
||||
borderRadius: '8px',
|
||||
padding: '24px',
|
||||
textAlign: 'center',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
backgroundColor: '#f8f9fa',
|
||||
},
|
||||
dropzoneActive: {
|
||||
borderColor: '#007bff',
|
||||
backgroundColor: '#e7f1ff',
|
||||
},
|
||||
dropzoneDisabled: {
|
||||
cursor: 'not-allowed',
|
||||
opacity: 0.7,
|
||||
},
|
||||
hiddenInput: {
|
||||
display: 'none',
|
||||
},
|
||||
content: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
},
|
||||
icon: {
|
||||
fontSize: '32px',
|
||||
},
|
||||
text: {
|
||||
fontSize: '14px',
|
||||
color: '#495057',
|
||||
fontWeight: 500,
|
||||
},
|
||||
hint: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
},
|
||||
uploading: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
color: '#007bff',
|
||||
},
|
||||
spinner: {
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
border: '2px solid #007bff',
|
||||
borderTopColor: 'transparent',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 1s linear infinite',
|
||||
},
|
||||
error: {
|
||||
marginTop: '8px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: '#f8d7da',
|
||||
color: '#721c24',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
}
|
||||
|
||||
// Add keyframes for spinner animation
|
||||
const styleSheet = document.createElement('style')
|
||||
styleSheet.textContent = `
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`
|
||||
document.head.appendChild(styleSheet)
|
||||
|
||||
export default AttachmentUpload
|
||||
64
frontend/src/components/TaskAttachments.tsx
Normal file
64
frontend/src/components/TaskAttachments.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { AttachmentUpload } from './AttachmentUpload'
|
||||
import { AttachmentList } from './AttachmentList'
|
||||
|
||||
interface TaskAttachmentsProps {
|
||||
taskId: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export function TaskAttachments({ taskId, title = 'Attachments' }: TaskAttachmentsProps) {
|
||||
const [refreshKey, setRefreshKey] = useState(0)
|
||||
const [expanded, setExpanded] = useState(true)
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setRefreshKey(prev => prev + 1)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header} onClick={() => setExpanded(!expanded)}>
|
||||
<span style={styles.title}>{title}</span>
|
||||
<span style={styles.toggleIcon}>{expanded ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div style={styles.content}>
|
||||
<AttachmentUpload taskId={taskId} onUploadComplete={handleRefresh} />
|
||||
<AttachmentList key={refreshKey} taskId={taskId} onRefresh={handleRefresh} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
container: {
|
||||
backgroundColor: '#f8f9fa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e9ecef',
|
||||
marginTop: '16px',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '12px 16px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
},
|
||||
title: {
|
||||
fontWeight: 600,
|
||||
fontSize: '14px',
|
||||
color: '#495057',
|
||||
},
|
||||
toggleIcon: {
|
||||
fontSize: '12px',
|
||||
color: '#6c757d',
|
||||
},
|
||||
content: {
|
||||
borderTop: '1px solid #e9ecef',
|
||||
padding: '16px',
|
||||
},
|
||||
}
|
||||
|
||||
export default TaskAttachments
|
||||
130
frontend/src/services/attachments.ts
Normal file
130
frontend/src/services/attachments.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import api from './api'
|
||||
|
||||
export interface AttachmentVersion {
|
||||
id: string
|
||||
version: number
|
||||
file_size: number
|
||||
checksum: string
|
||||
uploaded_by: string | null
|
||||
uploader_name: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
id: string
|
||||
task_id: string
|
||||
filename: string
|
||||
original_filename: string
|
||||
mime_type: string
|
||||
file_size: number
|
||||
current_version: number
|
||||
is_encrypted: boolean
|
||||
uploaded_by: string | null
|
||||
uploader_name: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AttachmentDetail extends Attachment {
|
||||
versions: AttachmentVersion[]
|
||||
}
|
||||
|
||||
export interface AttachmentListResponse {
|
||||
attachments: Attachment[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface VersionHistoryResponse {
|
||||
attachment_id: string
|
||||
filename: string
|
||||
versions: AttachmentVersion[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export const attachmentService = {
|
||||
async uploadAttachment(taskId: string, file: File): Promise<Attachment> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const response = await api.post(`/api/tasks/${taskId}/attachments`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listAttachments(taskId: string): Promise<AttachmentListResponse> {
|
||||
const response = await api.get(`/api/tasks/${taskId}/attachments`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getAttachment(attachmentId: string): Promise<AttachmentDetail> {
|
||||
const response = await api.get(`/api/attachments/${attachmentId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async downloadAttachment(attachmentId: string, version?: number): Promise<void> {
|
||||
const url = version
|
||||
? `/api/attachments/${attachmentId}/download?version=${version}`
|
||||
: `/api/attachments/${attachmentId}/download`
|
||||
|
||||
const response = await api.get(url, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
|
||||
// Get filename from content-disposition header or use default
|
||||
const contentDisposition = response.headers['content-disposition']
|
||||
let filename = 'download'
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
|
||||
if (filenameMatch && filenameMatch[1]) {
|
||||
filename = filenameMatch[1].replace(/['"]/g, '')
|
||||
}
|
||||
}
|
||||
|
||||
// Create download link
|
||||
const blob = new Blob([response.data])
|
||||
const downloadUrl = window.URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = downloadUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
window.URL.revokeObjectURL(downloadUrl)
|
||||
},
|
||||
|
||||
async deleteAttachment(attachmentId: string): Promise<void> {
|
||||
await api.delete(`/api/attachments/${attachmentId}`)
|
||||
},
|
||||
|
||||
async getVersionHistory(attachmentId: string): Promise<VersionHistoryResponse> {
|
||||
const response = await api.get(`/api/attachments/${attachmentId}/versions`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async restoreVersion(attachmentId: string, version: number): Promise<void> {
|
||||
await api.post(`/api/attachments/${attachmentId}/restore/${version}`)
|
||||
},
|
||||
|
||||
formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
},
|
||||
|
||||
getFileIcon(mimeType: string): string {
|
||||
if (mimeType.startsWith('image/')) return '🖼️'
|
||||
if (mimeType.includes('pdf')) return '📄'
|
||||
if (mimeType.includes('word') || mimeType.includes('document')) return '📝'
|
||||
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return '📊'
|
||||
if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return '📽️'
|
||||
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('archive')) return '📦'
|
||||
return '📎'
|
||||
},
|
||||
}
|
||||
|
||||
export default attachmentService
|
||||
Reference in New Issue
Block a user