From 4a3cb63263f998b20cc10f14e6ba76e059378724 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 30 Oct 2025 23:00:43 +0100 Subject: [PATCH] created_at swagger --- .../202510300000_initial_schema/up.sql | 2 +- backend/src/auth/passkeys.rs | 4 + backend/src/models.rs | 2 +- backend/src/openapi.rs | 5 +- backend/src/routes/documents.rs | 14 +- backend/src/routes/folders.rs | 2 +- backend/src/routes/mod.rs | 61 +++++++-- backend/src/routes/webdav/mod.rs | 2 +- backend/src/schema.rs | 2 +- backend/tests/common/mod.rs | 3 +- docs/document-model.md | 2 +- frontend/src/documents/documentSummary.js | 12 +- frontend/src/index.jsx | 2 +- frontend/src/preview/PreviewWorkspace.jsx | 127 +++++++++++++----- frontend/src/styles.css | 119 +++++++--------- 15 files changed, 217 insertions(+), 142 deletions(-) diff --git a/backend/migrations/202510300000_initial_schema/up.sql b/backend/migrations/202510300000_initial_schema/up.sql index c6d4296..b39123c 100644 --- a/backend/migrations/202510300000_initial_schema/up.sql +++ b/backend/migrations/202510300000_initial_schema/up.sql @@ -65,7 +65,7 @@ CREATE TABLE documents ( original_name VARCHAR(255) NOT NULL, content_type VARCHAR(100), folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, - uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, diff --git a/backend/src/auth/passkeys.rs b/backend/src/auth/passkeys.rs index 35d0548..7917541 100644 --- a/backend/src/auth/passkeys.rs +++ b/backend/src/auth/passkeys.rs @@ -56,6 +56,7 @@ impl PreparedPasskey { pub struct RegistrationChallengeResponse { pub challenge_id: Uuid, #[serde(flatten)] + #[schema(value_type = Object)] pub challenge: CreationChallengeResponse, } @@ -64,6 +65,7 @@ pub struct RegistrationChallengeResponse { pub struct AuthenticationChallengeResponse { pub challenge_id: Uuid, #[serde(flatten)] + #[schema(value_type = Object)] pub challenge: RequestChallengeResponse, } @@ -651,6 +653,7 @@ impl From for PasskeySummary { #[serde(rename_all = "camelCase")] pub struct PasskeyRegistrationFinishPayload { pub challenge_id: Uuid, + #[schema(value_type = Object)] pub credential: RegisterPublicKeyCredential, #[serde(default)] pub nickname: Option, @@ -666,5 +669,6 @@ pub struct PasskeyLoginStartPayload { #[serde(rename_all = "camelCase")] pub struct PasskeyLoginFinishPayload { pub challenge_id: Uuid, + #[schema(value_type = Object)] pub credential: PublicKeyCredential, } diff --git a/backend/src/models.rs b/backend/src/models.rs index 071937c..3273eaa 100644 --- a/backend/src/models.rs +++ b/backend/src/models.rs @@ -240,7 +240,7 @@ pub struct Document { pub original_name: String, pub content_type: Option, pub folder_id: Option, - pub uploaded_at: NaiveDateTime, + pub created_at: NaiveDateTime, pub updated_at: NaiveDateTime, pub deleted_at: Option, pub metadata: serde_json::Value, diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs index b31f422..ed8a59c 100644 --- a/backend/src/openapi.rs +++ b/backend/src/openapi.rs @@ -721,6 +721,7 @@ pub mod schemas { #[derive(Serialize, Deserialize, ToSchema)] pub struct SignupFinishRequest { pub signup_token: String, + #[schema(value_type = Object)] pub credential: RegisterPublicKeyCredential, #[schema(nullable)] pub nickname: Option, @@ -867,7 +868,7 @@ pub mod schemas { pub content_type: Option, #[schema(nullable)] pub folder_id: Option, - pub uploaded_at: String, + pub created_at: String, pub updated_at: String, #[schema(nullable)] pub deleted_at: Option, @@ -1038,7 +1039,7 @@ pub mod schemas { #[schema(nullable)] pub version_number: Option, #[schema(nullable)] - pub uploaded_at: Option, + pub created_at: Option, } #[derive(Serialize, Deserialize, ToSchema)] diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs index 35d05ac..cea7bb8 100644 --- a/backend/src/routes/documents.rs +++ b/backend/src/routes/documents.rs @@ -108,7 +108,7 @@ pub struct DocumentCheckResponse { #[serde(skip_serializing_if = "Option::is_none")] pub version_number: Option, #[serde(skip_serializing_if = "Option::is_none")] - pub uploaded_at: Option, + pub created_at: Option, } #[derive(Serialize, ToSchema)] @@ -136,7 +136,7 @@ pub struct DocumentResponse { pub original_name: String, pub content_type: Option, pub folder_id: Option, - pub uploaded_at: String, + pub created_at: String, pub updated_at: String, pub deleted_at: Option, pub issued_at: Option, @@ -554,14 +554,14 @@ pub async fn list_documents( if !by_id.is_empty() { let mut remaining: Vec = by_id.into_values().collect(); - remaining.sort_by(|a, b| b.uploaded_at.cmp(&a.uploaded_at)); + remaining.sort_by(|a, b| b.created_at.cmp(&a.created_at)); ordered.extend(remaining); } ordered } else { docs_query - .order(documents::uploaded_at.desc()) + .order(documents::created_at.desc()) .load(&mut conn)? }; @@ -627,7 +627,7 @@ pub async fn check_document( filename: Some(document.filename.clone()), version_id: Some(version.id), version_number: Some(version.version_number), - uploaded_at: Some(to_iso(document.uploaded_at)), + created_at: Some(to_iso(document.created_at)), })) } else { Ok(Json(DocumentCheckResponse { @@ -637,7 +637,7 @@ pub async fn check_document( filename: None, version_id: None, version_number: None, - uploaded_at: None, + created_at: None, })) } } @@ -2190,7 +2190,7 @@ pub(crate) fn to_document_response( original_name: doc.original_name, content_type: doc.content_type, folder_id: doc.folder_id, - uploaded_at: to_iso(doc.uploaded_at), + created_at: to_iso(doc.created_at), updated_at: to_iso(doc.updated_at), deleted_at: doc.deleted_at.map(to_iso), issued_at: doc.issued_at.map(to_iso), diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs index be691e2..6593003 100644 --- a/backend/src/routes/folders.rs +++ b/backend/src/routes/folders.rs @@ -308,7 +308,7 @@ pub async fn list_folder_contents( let docs_query = documents::table .filter(documents::deleted_at.is_null()) .filter(documents::tenant_id.eq(tenant_id)) - .order(documents::uploaded_at.desc()); + .order(documents::created_at.desc()); let docs: Vec = if let Some(current_folder) = folder_id { docs_query diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs index db3200e..c5b163c 100644 --- a/backend/src/routes/mod.rs +++ b/backend/src/routes/mod.rs @@ -2,7 +2,7 @@ use axum::http::HeaderValue; use axum::{ extract::DefaultBodyLimit, middleware, - response::Json, + response::{Html, Json}, routing::{delete, get, patch, post}, Router, }; @@ -164,24 +164,26 @@ pub fn create_router(state: AppState) -> Router<()> { .nest("/api/assets", assets_routes) .layer(middleware::from_extractor_with_state::(protected_state)); - let openapi_arc = Arc::new(ApiDoc::openapi()); - let docs_route = Router::new().route( - "/api/docs/openapi.json", - get({ - let spec = openapi_arc.clone(); - move || { - let spec = spec.clone(); - async move { Json((*spec).clone()) } - } - }), - ); - let upload_limit = state.config.upload_body_limit_bytes; + let openapi_spec = Arc::new(ApiDoc::openapi()); + let docs_router = Router::new() + .route( + "/api/docs", + get(move || async { Html(render_swagger_ui("/api/docs/openapi.json")) }), + ) + .route( + "/api/docs/openapi.json", + get({ + let spec = openapi_spec.clone(); + move || async move { Json((*spec).clone()) } + }), + ); + Router::new() .merge(download_routes) .merge(protected_routes) - .merge(docs_route) + .merge(docs_router) .nest("/api/auth", auth_routes) .route("/api/health", get(health::health_check)) .with_state(state) @@ -196,3 +198,34 @@ pub fn create_router(state: AppState) -> Router<()> { .on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)), ) } + +fn render_swagger_ui(spec_url: &str) -> String { + format!( + r#" + + + + Papercrate API Docs + + + + +
+ + + +"# + ) +} diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs index 967325a..5642adf 100644 --- a/backend/src/routes/webdav/mod.rs +++ b/backend/src/routes/webdav/mod.rs @@ -273,7 +273,7 @@ fn fetch_folder_contents( }; let documents: Vec = docs_query - .order(documents_dsl::uploaded_at.desc()) + .order(documents_dsl::created_at.desc()) .load(&mut conn)?; let version_ids: Vec = documents.iter().map(|doc| doc.current_version_id).collect(); diff --git a/backend/src/schema.rs b/backend/src/schema.rs index 09cfef1..bcb4116 100644 --- a/backend/src/schema.rs +++ b/backend/src/schema.rs @@ -88,7 +88,7 @@ diesel::table! { #[max_length = 100] content_type -> Nullable, folder_id -> Nullable, - uploaded_at -> Timestamptz, + created_at -> Timestamptz, updated_at -> Timestamptz, deleted_at -> Nullable, metadata -> Jsonb, diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index ab6c082..52967f6 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -37,7 +37,8 @@ use tower::util::ServiceExt; use uuid::Uuid; const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); -const RESET_SCHEMA_SQL: &str = include_str!("../../migrations/202510300000_initial_schema/down.sql"); +const RESET_SCHEMA_SQL: &str = + include_str!("../../migrations/202510300000_initial_schema/down.sql"); static DB_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); diff --git a/docs/document-model.md b/docs/document-model.md index d684719..45f182c 100644 --- a/docs/document-model.md +++ b/docs/document-model.md @@ -22,7 +22,7 @@ fields: row; updated whenever a new version is promoted. - `deleted_at (timestamptz, nullable)` – Soft-delete marker; non-NULL rows are treated as living in the trash. -- `uploaded_at / updated_at (timestamptz)` – Audit stamps; `updated_at` reflects +- `created_at / updated_at (timestamptz)` – Audit stamps; `updated_at` reflects metadata or version changes. Other indexes enforce per-tenant uniqueness for `(folder, filename)` and support diff --git a/frontend/src/documents/documentSummary.js b/frontend/src/documents/documentSummary.js index 6d78181..371cac1 100644 --- a/frontend/src/documents/documentSummary.js +++ b/frontend/src/documents/documentSummary.js @@ -58,9 +58,8 @@ export const describeDocumentSummary = (document, options = {}) => { originalName: '', mimeTypeLabel: '—', sizeLabel: '—', - uploadedAtLabel: '—', - issuedAtLabel: '—', createdAtLabel: '—', + issuedAtLabel: '—', updatedAtLabel: '—', pageCount: null, pageCountLabel: '—', @@ -88,9 +87,8 @@ export const describeDocumentSummary = (document, options = {}) => { const pageCount = coercePageCount(metadata); const pageCountLabel = Number.isFinite(pageCount) ? String(pageCount) : '—'; - const uploadedAtLabel = formatDateTime(document.uploaded_at); - const issuedAtLabel = formatDateTime(document.issued_at); const createdAtLabel = formatDateTime(document.created_at); + const issuedAtLabel = formatDateTime(document.issued_at); const updatedAtLabel = formatDateTime(document.updated_at); const folderLabel = document.folder_path || document.folder_name || null; @@ -107,12 +105,11 @@ export const describeDocumentSummary = (document, options = {}) => { : '—'; const summaryRows = [ - { key: 'uploaded', label: 'Uploaded', value: uploadedAtLabel }, + { key: 'created', label: 'Created', value: createdAtLabel }, { key: 'size', label: 'Size', value: sizeLabel }, { key: 'type', label: 'Type', value: mimeTypeLabel }, { key: 'issued', label: 'Issued', value: issuedAtLabel }, { key: 'pages', label: 'Pages', value: pageCountLabel }, - { key: 'created', label: 'Created', value: createdAtLabel }, { key: 'updated', label: 'Updated', value: updatedAtLabel }, { key: 'folder', label: 'Folder', value: folderLabel || '—' }, { key: 'tags', label: 'Tags', value: tagsSummary }, @@ -124,9 +121,8 @@ export const describeDocumentSummary = (document, options = {}) => { originalName, mimeTypeLabel, sizeLabel, - uploadedAtLabel, - issuedAtLabel, createdAtLabel, + issuedAtLabel, updatedAtLabel, pageCount, pageCountLabel, diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index a931621..75a91e3 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -330,7 +330,7 @@ const isAssetEquivalent = (lhs, rhs) => { && lhsPrimaryMetadata?.height === rhsPrimaryMetadata?.height && lhs.mime_type === rhs.mime_type && lhs.asset_type === rhs.asset_type - && lhs.created_at === rhs.created_at + && lhs.updated_at === rhs.updated_at && lhsCardinality === rhsCardinality && objectsComparable ); diff --git a/frontend/src/preview/PreviewWorkspace.jsx b/frontend/src/preview/PreviewWorkspace.jsx index b14661b..924fc63 100644 --- a/frontend/src/preview/PreviewWorkspace.jsx +++ b/frontend/src/preview/PreviewWorkspace.jsx @@ -13,21 +13,52 @@ const PreviewWorkspace = ({ const title = document.title; const summary = describeDocumentSummary(document); - const baseSummaryRows = summary.summaryRows.filter((row) => { - if (row.key === 'pages') { - return Number.isFinite(summary.pageCount); + const correspondents = Array.isArray(document.correspondents) + ? document.correspondents.map((entry) => entry?.name).filter(Boolean).join(', ') + : ''; + const tags = Array.isArray(document.tags) + ? document.tags.map((tag) => tag?.label).filter(Boolean).join(', ') + : ''; + + const formatDateTime = (value) => { + if (!value) { + return '—'; } - if (row.key === 'folder') { - return Boolean(summary.folderLabel); - } - return true; - }); - const summaryRows = [ - ...baseSummaryRows, + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '—' : date.toLocaleString(); + }; + + const detailItems = [ + { label: 'Title', value: summary.title || '—' }, + { label: 'Archive Reference', value: document.archive_serial || '—' }, + { label: 'Issued On', value: formatDateTime(document.issued_at) }, + { label: 'Correspondent', value: correspondents || '—' }, + { label: 'Document Type', value: document.document_type || '—' }, { - key: 'original-name', - label: 'Original filename', - value: document.original_name || '—', + label: 'Filename', + value: document.archive_path || document.filename || '—', + }, + { label: 'Tags', value: tags || '—' }, + ]; + + const metadataItems = [ + { label: 'Modified At', value: formatDateTime(document.updated_at) }, + { label: 'Created At', value: formatDateTime(document.created_at) }, + { + label: 'Media Filename', + value: document.current_version?.filename || document.archive_path || '—', + }, + { + label: 'SHA-256 Checksum', + value: document.current_version?.checksum || '—', + }, + { + label: 'Original File Size', + value: summary.sizeLabel, + }, + { + label: 'Original MIME Type', + value: document.content_type || '—', }, ]; const metadata = @@ -35,30 +66,54 @@ const PreviewWorkspace = ({ return (
- +
+
+

Notes

+

Custom notes will be editable here once the feature lands.

+
+
+

History

+

Change history will be displayed here in an upcoming release.

+
+
+

Permissions

+

Access control management is planned and will surface here.

+
+
{!previewEntry?.url ? (
Loading preview…
diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 253acff..2796232 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -665,37 +665,13 @@ button.danger:hover:not([disabled]) { .preview-workspace { flex: 1; - display: flex; + display: grid; + grid-template-columns: minmax(0, 30em) minmax(0, 1fr); gap: 1.5rem; min-height: 0; padding: 1rem 1.5rem; } -.preview-workspace__header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 0.75rem 1.5rem 0; -} - -.preview-workspace__meta { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.preview-workspace__meta h2 { - margin: 0; - font-size: 1.1rem; -} - -.preview-workspace__meta .meta { - display: block; - font-size: 0.8rem; - color: var(--muted); -} - .document-drag-preview { position: fixed; pointer-events: none; @@ -794,75 +770,84 @@ button.danger:hover:not([disabled]) { pointer-events: none; } -.preview-workspace__actions { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.preview-workspace__sidebar { - flex: 0 0 20em; +.preview-workspace__details { display: flex; flex-direction: column; - gap: 1rem; - max-width: 100%; - overflow: auto; + gap: 1.5rem; + overflow-y: auto; + min-height: 0; } -.preview-workspace__info { +.preview-section { background: var(--surface-subtle); - padding: 1rem; box-shadow: inset 0 0 0 1px var(--outline-subtle); + padding: 1.25rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.75rem; } -.preview-workspace__summary { +.preview-section__title { + margin: 0; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0.01em; +} + +.preview-section__list { margin: 0; padding: 0; list-style: none; - display: flex; - flex-direction: column; - gap: 0.5rem; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 0.75rem 1.25rem; } -.preview-workspace__summary-row { +.preview-section__item { display: flex; flex-direction: column; gap: 0.25rem; } -.preview-workspace__summary-row dt { +.preview-section__item dt { font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.04em; color: var(--muted); } -.preview-workspace__summary-row dd { +.preview-section__item dd { margin: 0; font-size: 0.9rem; font-weight: 500; -} - -.preview-workspace__metadata { - background: var(--surface-subtle); - padding: 1rem; - box-shadow: inset 0 0 0 1px var(--outline-subtle); - font-size: 0.85rem; - overflow: auto; - max-height: 40vh; -} - -.preview-workspace__metadata h4 { - margin: 0 0 0.5rem; - font-size: 0.9rem; -} - -.preview-workspace__metadata pre { - margin: 0; - white-space: pre-wrap; word-break: break-word; } +.preview-section__placeholder { + margin: 0; + font-size: 0.9rem; + color: var(--muted); +} + +.preview-section__payload { + font-size: 0.85rem; +} + +.preview-section__payload summary { + cursor: pointer; + font-weight: 500; + color: var(--accent-strong, var(--accent)); +} + +.preview-section__payload pre { + margin: 0.75rem 0 0; + padding: 0.75rem; + background: var(--surface); + box-shadow: inset 0 0 0 1px var(--outline-subtle); + border-radius: 6px; + max-height: 280px; + overflow: auto; + font-size: 0.8rem; +} + .preview-workspace__viewer { flex: 1; min-width: 0;