revert document types

This commit is contained in:
2025-10-31 12:56:09 +01:00
parent 0a87f33d89
commit faf8fec9c7
20 changed files with 12 additions and 1984 deletions
@@ -1,6 +0,0 @@
DROP INDEX IF EXISTS documents_document_type_idx;
ALTER TABLE documents
DROP COLUMN IF EXISTS document_type_id;
DROP TABLE IF EXISTS document_types;
@@ -1,11 +0,0 @@
CREATE TABLE document_types (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
CONSTRAINT document_types_unique_name UNIQUE (tenant_id, name)
);
ALTER TABLE documents
ADD COLUMN document_type_id UUID REFERENCES document_types(id) ON DELETE SET NULL;
CREATE INDEX documents_document_type_idx ON documents(document_type_id);
-18
View File
@@ -248,23 +248,6 @@ pub struct Document {
pub title: String,
pub current_version_id: Uuid,
pub tenant_id: Uuid,
pub document_type_id: Option<Uuid>,
}
#[derive(Debug, Clone, Queryable, Identifiable)]
#[diesel(table_name = document_types)]
pub struct DocumentType {
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_types)]
pub struct NewDocumentType {
pub id: Uuid,
pub tenant_id: Uuid,
pub name: String,
}
#[derive(Debug, Insertable)]
@@ -280,7 +263,6 @@ pub struct NewDocument {
pub issued_at: Option<NaiveDateTime>,
pub title: String,
pub tenant_id: Uuid,
pub document_type_id: Option<Uuid>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
+2 -9
View File
@@ -10,7 +10,6 @@ impl OpenApi for ApiDoc {
doc.merge(crate::routes::documents::DocumentsApiDoc::openapi());
doc.merge(crate::routes::folders::FoldersApiDoc::openapi());
doc.merge(crate::routes::tags::TagsApiDoc::openapi());
doc.merge(crate::routes::document_types::DocumentTypesApiDoc::openapi());
doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi());
doc.merge(crate::routes::profile::ProfileApiDoc::openapi());
@@ -44,10 +43,6 @@ impl OpenApi for ApiDoc {
.name("Tags")
.description(Some("Tag catalog"))
.build(),
TagBuilder::new()
.name("DocumentTypes")
.description(Some("Document type catalog"))
.build(),
TagBuilder::new()
.name("Correspondents")
.description(Some("Correspondent catalog"))
@@ -82,16 +77,14 @@ pub mod schemas {
CorrespondentSummary, CorrespondentUsage, CreateCorrespondentRequest,
UpdateCorrespondentRequest,
};
pub use crate::routes::document_types::{CreateDocumentTypeRequest, UpdateDocumentTypeRequest};
pub use crate::routes::documents::{
AssetObjectsQuery, AssetRequestQuery, AssignCorrespondentsRequest, AssignTagsRequest,
BulkCorrespondentAction, BulkCorrespondentResponse, BulkCorrespondentsRequest,
BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest,
BulkTagAction, BulkTagRequest, BulkTagResponse, CorrespondentAssignmentInput,
DocumentCheckQuery, DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery,
DocumentMetadataUpdate, DocumentResponse, DocumentStatusFilter, DocumentTypeResponse,
MoveDocumentRequest, RestoreDocumentRequest, TagResponse, UpdateDocumentRequest,
UploadDocumentForm,
DocumentMetadataUpdate, DocumentResponse, DocumentStatusFilter, MoveDocumentRequest,
RestoreDocumentRequest, TagResponse, UpdateDocumentRequest, UploadDocumentForm,
};
pub use crate::routes::folders::{
CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderContentsResponse,
-187
View File
@@ -1,187 +0,0 @@
use axum::{
extract::{Json, Path},
http::StatusCode,
};
use diesel::prelude::*;
use serde::Deserialize;
use utoipa::ToSchema;
use uuid::Uuid;
use crate::{
auth::TenantScopedConn,
error::{AppError, AppResult},
models::{DocumentType, NewDocumentType},
schema::document_types,
utils::named_entity::normalize_name,
};
use super::documents::DocumentTypeResponse;
#[derive(Deserialize, ToSchema)]
pub struct CreateDocumentTypeRequest {
pub name: String,
}
#[derive(Deserialize, ToSchema)]
pub struct UpdateDocumentTypeRequest {
pub name: Option<String>,
}
#[utoipa::path(
get,
path = "/api/document-types",
responses((status = 200, description = "Document types", body = [DocumentTypeResponse])),
tag = "DocumentTypes"
)]
pub async fn list_document_types(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<Json<Vec<DocumentTypeResponse>>> {
let types: Vec<DocumentType> = document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.order(document_types::name.asc())
.load(&mut conn)?;
Ok(Json(
types.into_iter().map(DocumentTypeResponse::from).collect(),
))
}
#[utoipa::path(
post,
path = "/api/document-types",
request_body = CreateDocumentTypeRequest,
responses((status = 201, description = "Document type created", body = DocumentTypeResponse)),
tag = "DocumentTypes"
)]
pub async fn create_document_type(
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateDocumentTypeRequest>,
) -> AppResult<(StatusCode, Json<DocumentTypeResponse>)> {
let name = normalize_name(&payload.name, || {
AppError::bad_request("name must not be empty")
})?;
let new_type = NewDocumentType {
id: Uuid::new_v4(),
tenant_id,
name: name.clone(),
};
match diesel::insert_into(document_types::table)
.values(&new_type)
.get_result::<DocumentType>(&mut conn)
{
Ok(created) => Ok((
StatusCode::CREATED,
Json(DocumentTypeResponse::from(created)),
)),
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_,
)) => Err(
AppError::conflict("a document type with that name already exists")
.with_code("duplicate_document_type"),
),
Err(err) => Err(AppError::from(err)),
}
}
#[utoipa::path(
patch,
path = "/api/document-types/{id}",
params(("id" = Uuid, Path, description = "Document type ID")),
request_body = UpdateDocumentTypeRequest,
responses((status = 200, description = "Document type updated", body = DocumentTypeResponse)),
tag = "DocumentTypes"
)]
pub async fn update_document_type(
Path(document_type_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
Json(payload): Json<UpdateDocumentTypeRequest>,
) -> AppResult<Json<DocumentTypeResponse>> {
let name_raw = payload
.name
.as_ref()
.ok_or_else(|| AppError::bad_request("name must be provided and not empty"))?;
let name = normalize_name(name_raw, || {
AppError::bad_request("name must be provided and not empty")
})?;
let target = document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.find(document_type_id);
let update_result = diesel::update(target.clone())
.set(document_types::name.eq(&name))
.get_result::<DocumentType>(&mut conn);
match update_result {
Ok(updated) => Ok(Json(DocumentTypeResponse::from(updated))),
Err(diesel::result::Error::NotFound) => Err(AppError::not_found()),
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_,
)) => Err(
AppError::conflict("a document type with that name already exists")
.with_code("duplicate_document_type"),
),
Err(err) => Err(AppError::from(err)),
}
}
#[utoipa::path(
delete,
path = "/api/document-types/{id}",
params(("id" = Uuid, Path, description = "Document type ID")),
responses((status = 204, description = "Document type deleted")),
tag = "DocumentTypes"
)]
pub async fn delete_document_type(
Path(document_type_id): Path<Uuid>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<StatusCode> {
let affected = diesel::delete(
document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.find(document_type_id),
)
.execute(&mut conn)?;
if affected == 0 {
return Err(AppError::not_found());
}
Ok(StatusCode::NO_CONTENT)
}
#[derive(utoipa::OpenApi)]
#[openapi(
paths(
crate::routes::document_types::list_document_types,
crate::routes::document_types::create_document_type,
crate::routes::document_types::update_document_type,
crate::routes::document_types::delete_document_type
),
components(schemas(
crate::routes::document_types::CreateDocumentTypeRequest,
crate::routes::document_types::UpdateDocumentTypeRequest,
crate::routes::documents::DocumentTypeResponse
))
)]
pub struct DocumentTypesApiDoc;
+3 -193
View File
@@ -37,13 +37,12 @@ use crate::documents::{
use crate::error::{AppError, AppResult};
use crate::jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_INDEX_DOCUMENT_TEXT};
use crate::models::{
Document, DocumentAsset, DocumentAssetObject, DocumentType, DocumentVersion, NewDocument,
NewDocumentTag, NewDocumentVersion, Tag,
Document, DocumentAsset, DocumentAssetObject, DocumentVersion, NewDocument, NewDocumentTag,
NewDocumentVersion, Tag,
};
use crate::schema::{
document_asset_objects, document_assets, document_correspondents, document_tags,
document_types, document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl,
tags,
document_versions, documents, folders, refresh_tokens::dsl as refresh_dsl, tags,
};
use crate::state::AppState;
use crate::utils::{
@@ -68,7 +67,6 @@ pub struct DocumentListQuery {
pub query: Option<String>,
pub tags: Option<String>,
pub correspondents: Option<String>,
pub document_types: Option<String>,
#[serde(default = "default_document_status_filter")]
#[schema(default = "active")]
pub status: DocumentStatusFilter,
@@ -141,21 +139,6 @@ impl From<Tag> for TagResponse {
}
}
#[derive(Clone, Serialize, Deserialize, ToSchema)]
pub struct DocumentTypeResponse {
pub id: Uuid,
pub name: String,
}
impl From<DocumentType> for DocumentTypeResponse {
fn from(value: DocumentType) -> Self {
Self {
id: value.id,
name: value.name,
}
}
}
#[derive(Serialize, ToSchema)]
pub struct DocumentResponse {
pub id: Uuid,
@@ -174,12 +157,6 @@ pub struct DocumentResponse {
pub issued_at: Option<String>,
#[schema(value_type = Object)]
pub metadata: Value,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub document_type_id: Option<Uuid>,
#[serde(skip_serializing_if = "Option::is_none")]
#[schema(nullable)]
pub document_type: Option<DocumentTypeResponse>,
pub tags: Vec<TagResponse>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub correspondents: Vec<DocumentCorrespondentResponse>,
@@ -223,9 +200,6 @@ pub struct UpdateDocumentRequest {
#[serde(default)]
#[schema(nullable, value_type = Object)]
pub metadata: Option<DocumentMetadataUpdate>,
#[serde(default)]
#[schema(nullable, value_type = Option<Uuid>)]
pub document_type_id: Option<Uuid>,
}
#[derive(Serialize, ToSchema)]
@@ -280,7 +254,6 @@ struct DocumentUpdateChangeset {
issued_at: Option<Option<NaiveDateTime>>,
metadata: Option<Value>,
updated_at: Option<NaiveDateTime>,
document_type_id: Option<Option<Uuid>>,
}
#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)]
@@ -325,7 +298,6 @@ struct UploadRequest {
correspondents: Vec<CorrespondentAssignmentInput>,
issued_at_override: Option<NaiveDateTime>,
skip_if_existing: bool,
document_type_id: Option<Uuid>,
}
enum UploadOutcome {
@@ -402,7 +374,6 @@ pub async fn list_documents(
query,
tags,
correspondents,
document_types,
status,
} = params;
@@ -435,12 +406,6 @@ pub async fn list_documents(
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_owned());
let document_types_param = document_types
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_owned());
let include_descendants = include_descendants.unwrap_or(true);
match (folder_id, include_descendants) {
@@ -579,20 +544,6 @@ pub async fn list_documents(
}
}
if let Some(document_types_param) = document_types_param.as_ref() {
let type_ids: Result<Vec<Uuid>, _> = document_types_param
.split(',')
.map(|s| Uuid::parse_str(s.trim()))
.collect();
if let Ok(ids) = type_ids {
if !ids.is_empty() {
let filter_values: Vec<Option<Uuid>> = ids.into_iter().map(Some).collect();
docs_query = docs_query.filter(documents::document_type_id.eq_any(filter_values));
}
}
}
if let Some(ref set) = filter_ids {
if set.is_empty() {
return Ok(Json(vec![]));
@@ -735,8 +686,6 @@ pub async fn get_document(
.find(doc.current_version_id)
.first(&mut conn)?;
let doc_type = load_document_type(&mut conn, tenant_id, doc.document_type_id)?;
let tags_map = load_tags_for_documents(&mut conn, &[document_id])?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[document_id])?;
let version_id = current_version.id;
@@ -753,7 +702,6 @@ pub async fn get_document(
tags_map.get(&document_id).cloned(),
correspondents_map.remove(&document_id).unwrap_or_default(),
Some((version_response, assets)),
doc_type,
)?,
}))
}
@@ -786,7 +734,6 @@ pub async fn upload_document(
let mut issued_at_override: Option<NaiveDateTime> = None;
let mut skip_if_existing = false;
let mut title_override: Option<String> = None;
let mut document_type_id: Option<Uuid> = None;
while let Some(field) = multipart.next_field().await.map_err(|err| {
let msg = format!("invalid multipart data: {err}");
@@ -905,20 +852,6 @@ pub async fn upload_document(
"1" | "true" | "yes"
);
}
Some("document_type_id") => {
let value = field.text().await.map_err(|err| {
let msg = format!("invalid document_type_id: {err}");
error!(error = %err, "invalid document_type payload");
AppError::bad_request(msg)
})?;
let trimmed = value.trim();
if !trimmed.is_empty() {
let parsed = Uuid::parse_str(trimmed).map_err(|_| {
AppError::bad_request("document_type_id must be a valid UUID")
})?;
document_type_id = Some(parsed);
}
}
_ => {}
}
}
@@ -949,7 +882,6 @@ pub async fn upload_document(
correspondents,
issued_at_override,
skip_if_existing,
document_type_id,
};
let outcome = match process_upload(&state, request, tenant_id, user_id).await {
@@ -1482,46 +1414,6 @@ pub async fn update_document(
}
}
let doc_type_update =
classify_nullable(payload_obj.get("document_type_id")).map_err(AppError::bad_request)?;
match doc_type_update {
NullableValue::Omitted => {}
NullableValue::Null => {
if document.document_type_id.is_some() {
changes.document_type_id = Some(None);
has_changes = true;
}
}
NullableValue::String(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request(
"document_type_id must not be empty when provided",
));
}
let parsed = Uuid::parse_str(trimmed)
.map_err(|_| AppError::bad_request("document_type_id must be a valid UUID"))?;
if document.document_type_id != Some(parsed) {
let exists = document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.find(parsed)
.first::<DocumentType>(&mut conn)
.optional()?;
if exists.is_none() {
return Err(AppError::bad_request(
"document_type_id does not exist for this tenant",
));
}
changes.document_type_id = Some(Some(parsed));
has_changes = true;
}
}
}
if !has_changes {
return Err(AppError::bad_request("no changes provided"));
}
@@ -1555,8 +1447,6 @@ pub async fn update_document(
.find(document.current_version_id)
.first(&mut conn)?;
let doc_type = load_document_type(&mut conn, tenant_id, document.document_type_id)?;
if title_changed {
if let Err(err) = enqueue_job(
&mut conn,
@@ -1593,7 +1483,6 @@ pub async fn update_document(
tags_map.get(&document_id).cloned(),
correspondents_map.remove(&document_id).unwrap_or_default(),
Some((version_response, assets)),
doc_type,
)?,
}))
}
@@ -2195,23 +2084,8 @@ async fn process_upload(
correspondents,
issued_at_override,
skip_if_existing,
document_type_id,
} = request;
if let Some(type_id) = document_type_id {
let mut conn = state.db_for_tenant(tenant_id)?;
let exists = document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.find(type_id)
.first::<DocumentType>(&mut conn)
.optional()?;
if exists.is_none() {
return Err(AppError::bad_request(
"document_type_id does not exist for this tenant",
));
}
}
if let Some(folder) = folder_id {
let mut conn = state.db_for_tenant(tenant_id)?;
ensure_folder_exists_on_conn(&mut conn, tenant_id, folder)?;
@@ -2292,24 +2166,6 @@ async fn process_upload(
)?;
}
if let Some(type_id) = document_type_id {
if document.document_type_id != Some(type_id) {
let now = Utc::now().naive_utc();
diesel::update(
documents::table
.find(document.id)
.filter(documents::tenant_id.eq(tenant_id)),
)
.set((
documents::document_type_id.eq(Some(type_id)),
documents::updated_at.eq(now),
))
.execute(&mut conn)?;
document.document_type_id = Some(type_id);
document.updated_at = now;
}
}
if document.deleted_at.is_some() {
let now = Utc::now().naive_utc();
diesel::update(documents::table.find(document.id))
@@ -2322,7 +2178,6 @@ async fn process_upload(
document.updated_at = now;
}
let doc_type = load_document_type(&mut conn, tenant_id, document.document_type_id)?;
let tags_map = load_tags_for_documents(&mut conn, &[document.id])?;
let mut correspondents_map =
load_correspondents_for_documents(&mut conn, &[document.id])?;
@@ -2346,7 +2201,6 @@ async fn process_upload(
tags,
correspondents,
Some((version_response, assets)),
doc_type,
)?,
}));
}
@@ -2386,7 +2240,6 @@ async fn process_upload(
title: derived_title.clone(),
metadata: metadata_value.clone(),
tenant_id,
document_type_id,
};
diesel::insert_into(documents::table)
.values(&new_document)
@@ -2445,7 +2298,6 @@ async fn process_upload(
)?;
}
let doc_type = load_document_type(&mut conn, tenant_id, document.document_type_id)?;
let tags_map = load_tags_for_documents(&mut conn, &[doc_id])?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &[doc_id])?;
let tags = tags_map.get(&doc_id).cloned();
@@ -2460,7 +2312,6 @@ async fn process_upload(
tags,
correspondents,
Some((to_version_response(version.clone()), Vec::new())),
doc_type,
)?,
}
};
@@ -2493,7 +2344,6 @@ pub(crate) fn to_document_response(
tags: Option<Vec<Tag>>,
correspondents: Vec<DocumentCorrespondentResponse>,
current_version: Option<(DocumentVersionResponse, Vec<DocumentAssetResponse>)>,
doc_type: Option<DocumentType>,
) -> AppResult<DocumentResponse> {
let current_version = if let Some((version, assets)) = current_version {
let download_path = build_download_path(state, &doc, user_id)?;
@@ -2518,11 +2368,6 @@ pub(crate) fn to_document_response(
deleted_at: doc.deleted_at.map(to_iso),
issued_at: doc.issued_at.map(to_iso),
metadata: doc.metadata,
document_type_id: doc.document_type_id,
document_type: doc_type.map(|typ| DocumentTypeResponse {
id: typ.id,
name: typ.name,
}),
tags: tags
.unwrap_or_default()
.into_iter()
@@ -2533,22 +2378,6 @@ pub(crate) fn to_document_response(
})
}
fn load_document_type(
conn: &mut PgConnection,
tenant_id: Uuid,
type_id: Option<Uuid>,
) -> diesel::QueryResult<Option<DocumentType>> {
if let Some(id) = type_id {
document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.find(id)
.first::<DocumentType>(conn)
.optional()
} else {
Ok(None)
}
}
pub(crate) fn hydrate_documents(
state: &AppState,
conn: &mut PgConnection,
@@ -2560,20 +2389,6 @@ pub(crate) fn hydrate_documents(
return Ok(Vec::new());
}
let type_ids: HashSet<Uuid> = docs.iter().filter_map(|doc| doc.document_type_id).collect();
let doc_type_map: HashMap<Uuid, DocumentType> = if type_ids.is_empty() {
HashMap::new()
} else {
let ids: Vec<Uuid> = type_ids.iter().copied().collect();
document_types::table
.filter(document_types::tenant_id.eq(tenant_id))
.filter(document_types::id.eq_any(&ids))
.load::<DocumentType>(conn)?
.into_iter()
.map(|typ| (typ.id, typ))
.collect()
};
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(conn, &doc_ids)?;
let mut correspondents_map = load_correspondents_for_documents(conn, &doc_ids)?;
@@ -2584,9 +2399,6 @@ pub(crate) fn hydrate_documents(
let tags = tags_map.get(&doc.id).cloned();
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
let current_version = primary_versions.get(&doc.id).cloned();
let doc_type = doc
.document_type_id
.and_then(|id| doc_type_map.get(&id).cloned());
responses.push(to_document_response(
state,
user_id,
@@ -2594,7 +2406,6 @@ pub(crate) fn hydrate_documents(
tags,
correspondents,
current_version,
doc_type,
)?);
}
@@ -2636,7 +2447,6 @@ pub(crate) fn hydrate_documents(
crate::routes::documents::DocumentResponse,
crate::routes::documents::DocumentDetailResponse,
crate::routes::documents::DocumentMetadataUpdate,
crate::routes::documents::DocumentTypeResponse,
crate::routes::documents::TagResponse,
crate::routes::documents::CorrespondentAssignmentInput,
crate::routes::documents::AssignCorrespondentsRequest,
-13
View File
@@ -17,7 +17,6 @@ use crate::{auth::AuthenticatedUser, openapi::ApiDoc, state::AppState};
pub mod auth;
pub mod correspondents;
pub mod document_types;
pub mod documents;
pub mod folders;
pub mod health;
@@ -133,17 +132,6 @@ pub fn create_router(state: AppState) -> Router<()> {
.route("/", get(tags::list_tags).post(tags::create_tag))
.route("/:id", patch(tags::update_tag).delete(tags::delete_tag));
let document_types_routes = Router::new()
.route(
"/",
get(document_types::list_document_types).post(document_types::create_document_type),
)
.route(
"/:id",
patch(document_types::update_document_type)
.delete(document_types::delete_document_type),
);
let correspondents_routes = Router::new()
.route(
"/",
@@ -171,7 +159,6 @@ pub fn create_router(state: AppState) -> Router<()> {
.nest("/api/documents", documents_routes)
.nest("/api/folders", folders_routes)
.nest("/api/tags", tags_routes)
.nest("/api/document-types", document_types_routes)
.nest("/api/correspondents", correspondents_routes)
.nest("/api/profile", profile_routes)
.nest("/api/assets", assets_routes)
-13
View File
@@ -62,15 +62,6 @@ diesel::table! {
}
}
diesel::table! {
document_types (id) {
id -> Uuid,
tenant_id -> Uuid,
#[max_length = 100]
name -> Varchar,
}
}
diesel::table! {
document_versions (id) {
id -> Uuid,
@@ -106,7 +97,6 @@ diesel::table! {
title -> Varchar,
current_version_id -> Uuid,
tenant_id -> Uuid,
document_type_id -> Nullable<Uuid>,
}
}
@@ -260,9 +250,7 @@ diesel::joinable!(document_tags -> documents (document_id));
diesel::joinable!(document_tags -> tags (tag_id));
diesel::joinable!(document_tags -> tenants (tenant_id));
diesel::joinable!(document_tags -> users (assigned_by));
diesel::joinable!(document_types -> tenants (tenant_id));
diesel::joinable!(document_versions -> tenants (tenant_id));
diesel::joinable!(documents -> document_types (document_type_id));
diesel::joinable!(documents -> folders (folder_id));
diesel::joinable!(documents -> tenants (tenant_id));
diesel::joinable!(folders -> tenants (tenant_id));
@@ -283,7 +271,6 @@ diesel::allow_tables_to_appear_in_same_query!(
document_assets,
document_correspondents,
document_tags,
document_types,
document_versions,
documents,
folders,
-10
View File
@@ -538,7 +538,6 @@ impl TestApp {
correspondents_json: None,
issued_at: None,
skip_existing: false,
document_type_id: None,
};
self.upload_document_with_extras(
path,
@@ -618,13 +617,6 @@ impl TestApp {
body.extend(b"\r\n");
}
if let Some(document_type_id) = extras.document_type_id {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"document_type_id\"\r\n\r\n");
body.extend(document_type_id.to_string().as_bytes());
body.extend(b"\r\n");
}
if extras.skip_existing {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\ntrue\r\n");
@@ -674,7 +666,6 @@ pub struct UploadExtras<'a> {
pub correspondents_json: Option<&'a str>,
pub issued_at: Option<&'a str>,
pub skip_existing: bool,
pub document_type_id: Option<Uuid>,
}
impl<'a> UploadExtras<'a> {
@@ -686,7 +677,6 @@ impl<'a> UploadExtras<'a> {
correspondents_json: None,
issued_at: None,
skip_existing: false,
document_type_id: None,
}
}
}
-127
View File
@@ -1,127 +0,0 @@
mod common;
use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde_json::json;
use uuid::Uuid;
#[derive(serde::Deserialize)]
struct DocumentTypeInfo {
id: Uuid,
name: String,
}
#[derive(serde::Deserialize)]
struct ApiErrorResponse {
error: String,
#[serde(default)]
code: Option<String>,
}
#[tokio::test]
async fn document_type_crud_flow() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "doctypecrud";
let username = "doctype_admin";
app.insert_user(username, password, "admin").await?;
let token = app.login_token(username, password).await?;
let create_invoices = app
.post_json(
"/api/document-types",
&json!({"name": "Invoices"}),
Some(&token),
)
.await?;
assert_eq!(create_invoices.status(), StatusCode::CREATED);
let invoices_body = body_to_vec(create_invoices.into_body()).await?;
let invoices: DocumentTypeInfo = serde_json::from_slice(&invoices_body)?;
let invoices_id = invoices.id;
let create_receipts = app
.post_json(
"/api/document-types",
&json!({"name": "Receipts"}),
Some(&token),
)
.await?;
assert_eq!(create_receipts.status(), StatusCode::CREATED);
let receipts_body = body_to_vec(create_receipts.into_body()).await?;
let receipts: DocumentTypeInfo = serde_json::from_slice(&receipts_body)?;
let list_resp = app.get("/api/document-types", Some(&token)).await?;
assert!(list_resp.status().is_success());
let list_body = body_to_vec(list_resp.into_body()).await?;
let mut all_types: Vec<DocumentTypeInfo> = serde_json::from_slice(&list_body)?;
all_types.sort_by(|a, b| a.name.cmp(&b.name));
assert_eq!(all_types.len(), 2);
assert_eq!(all_types[0].name, "Invoices");
assert_eq!(all_types[1].name, "Receipts");
let update_resp = app
.patch_json(
&format!("/api/document-types/{}", invoices_id),
&json!({"name": "Bills"}),
Some(&token),
)
.await?;
assert_eq!(update_resp.status(), StatusCode::OK);
let update_body = body_to_vec(update_resp.into_body()).await?;
let updated: DocumentTypeInfo = serde_json::from_slice(&update_body)?;
assert_eq!(updated.id, invoices_id);
assert_eq!(updated.name, "Bills");
let conflict_resp = app
.patch_json(
&format!("/api/document-types/{}", receipts.id),
&json!({"name": "Bills"}),
Some(&token),
)
.await?;
assert_eq!(conflict_resp.status(), StatusCode::CONFLICT);
let conflict_body = body_to_vec(conflict_resp.into_body()).await?;
let conflict_json: ApiErrorResponse = serde_json::from_slice(&conflict_body)?;
assert_eq!(
conflict_json.error,
"a document type with that name already exists"
);
assert_eq!(
conflict_json.code.as_deref(),
Some("duplicate_document_type")
);
let delete_receipts = app
.delete(
&format!("/api/document-types/{}", receipts.id),
Some(&token),
)
.await?;
assert_eq!(delete_receipts.status(), StatusCode::NO_CONTENT);
let list_after_resp = app.get("/api/document-types", Some(&token)).await?;
assert!(list_after_resp.status().is_success());
let list_after_body = body_to_vec(list_after_resp.into_body()).await?;
let list_after: Vec<DocumentTypeInfo> = serde_json::from_slice(&list_after_body)?;
assert_eq!(list_after.len(), 1);
assert_eq!(list_after[0].id, invoices_id);
assert_eq!(list_after[0].name, "Bills");
let delete_bills = app
.delete(
&format!("/api/document-types/{}", invoices_id),
Some(&token),
)
.await?;
assert_eq!(delete_bills.status(), StatusCode::NO_CONTENT);
let final_list_resp = app.get("/api/document-types", Some(&token)).await?;
let final_list_body = body_to_vec(final_list_resp.into_body()).await?;
let final_list: Vec<DocumentTypeInfo> = serde_json::from_slice(&final_list_body)?;
assert!(final_list.is_empty());
app.cleanup().await?;
Ok(())
}
-307
View File
@@ -30,21 +30,11 @@ struct DocumentInfo {
deleted_at: Option<String>,
issued_at: Option<String>,
metadata: Value,
#[serde(default)]
document_type_id: Option<Uuid>,
#[serde(default)]
document_type: Option<DocumentTypeInfo>,
tags: Vec<TagSummary>,
#[serde(default)]
current_version: Option<DocumentVersionPayload>,
}
#[derive(Deserialize)]
struct DocumentTypeInfo {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
struct DocumentVersionPayload {
id: Uuid,
@@ -72,8 +62,6 @@ struct DocumentAssetInfo {
struct DocumentListItem {
id: Uuid,
#[serde(default)]
document_type_id: Option<Uuid>,
#[serde(default)]
current_version: Option<DocumentVersionPayload>,
}
@@ -186,7 +174,6 @@ async fn upload_and_list_document() -> Result<()> {
assert_eq!(detail.document.title, "doc");
assert_eq!(detail.document.deleted_at, None);
assert!(detail.document.issued_at.is_none());
assert!(detail.document.document_type_id.is_none());
assert!(detail.document.tags.is_empty());
let current_version = detail
.document
@@ -420,7 +407,6 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
correspondents_json: None,
issued_at: None,
skip_existing: false,
document_type_id: None,
};
let first_upload = app
@@ -471,7 +457,6 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
correspondents_json: None,
issued_at: None,
skip_existing: true,
document_type_id: None,
};
let skip_resp = app
@@ -1453,298 +1438,6 @@ async fn list_documents_by_status_filter() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn document_upload_and_patch_document_type() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "doctypeupload";
let username = "doctype_user";
app.insert_user(username, password, "admin").await?;
let token = app.login_token(username, password).await?;
let invoices_resp = app
.post_json(
"/api/document-types",
&json!({"name": "Invoices"}),
Some(&token),
)
.await?;
assert_eq!(invoices_resp.status(), StatusCode::CREATED);
let invoices_body = body_to_vec(invoices_resp.into_body()).await?;
let invoices: DocumentTypeInfo = serde_json::from_slice(&invoices_body)?;
let receipts_resp = app
.post_json(
"/api/document-types",
&json!({"name": "Receipts"}),
Some(&token),
)
.await?;
assert_eq!(receipts_resp.status(), StatusCode::CREATED);
let receipts_body = body_to_vec(receipts_resp.into_body()).await?;
let receipts: DocumentTypeInfo = serde_json::from_slice(&receipts_body)?;
let upload_extras = UploadExtras {
title: Some("Invoice #1"),
metadata_json: None,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: false,
document_type_id: Some(invoices.id),
};
let upload_resp = app
.upload_document_with_extras(
"/api/documents",
"invoice.pdf",
"application/pdf",
b"invoice-bytes",
None,
upload_extras,
&token,
)
.await?;
assert!(upload_resp.status().is_success());
let upload_body = body_to_vec(upload_resp.into_body()).await?;
let mut detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
assert_eq!(detail.document.document_type_id, Some(invoices.id));
let doc_type_info = detail
.document
.document_type
.as_ref()
.expect("document type present");
assert_eq!(doc_type_info.name, "Invoices");
let document_id = detail.document.id;
let update_resp = app
.patch_json(
&format!("/api/documents/{}", document_id),
&json!({"document_type_id": receipts.id}),
Some(&token),
)
.await?;
assert!(update_resp.status().is_success());
let update_body = body_to_vec(update_resp.into_body()).await?;
detail = serde_json::from_slice(&update_body)?;
assert_eq!(detail.document.document_type_id, Some(receipts.id));
assert_eq!(
detail
.document
.document_type
.as_ref()
.map(|info| info.name.as_str()),
Some("Receipts")
);
let clear_resp = app
.patch_json(
&format!("/api/documents/{}", document_id),
&json!({"document_type_id": null}),
Some(&token),
)
.await?;
assert!(clear_resp.status().is_success());
let clear_body = body_to_vec(clear_resp.into_body()).await?;
detail = serde_json::from_slice(&clear_body)?;
assert!(detail.document.document_type_id.is_none());
assert!(detail.document.document_type.is_none());
let invalid_id = Uuid::new_v4();
let invalid_resp = app
.patch_json(
&format!("/api/documents/{}", document_id),
&json!({"document_type_id": invalid_id}),
Some(&token),
)
.await?;
assert_eq!(invalid_resp.status(), StatusCode::BAD_REQUEST);
let invalid_body = body_to_vec(invalid_resp.into_body()).await?;
let invalid_error: ApiErrorResponse = serde_json::from_slice(&invalid_body)?;
assert_eq!(
invalid_error.error,
"document_type_id does not exist for this tenant"
);
let final_detail_resp = app
.get(&format!("/api/documents/{}", document_id), Some(&token))
.await?;
let final_detail_body = body_to_vec(final_detail_resp.into_body()).await?;
let final_detail: DocumentDetail = serde_json::from_slice(&final_detail_body)?;
assert!(final_detail.document.document_type_id.is_none());
assert!(final_detail.document.document_type.is_none());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn list_documents_filtered_by_document_type() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "doctypefilter";
app.insert_user("doctypeuser", password, "admin").await?;
let token = app.login_token("doctypeuser", password).await?;
let invoices_resp = app
.post_json(
"/api/document-types",
&json!({"name": "Invoices"}),
Some(&token),
)
.await?;
assert_eq!(invoices_resp.status(), StatusCode::CREATED);
let invoices_body = body_to_vec(invoices_resp.into_body()).await?;
let invoices: DocumentTypeInfo = serde_json::from_slice(&invoices_body)?;
let receipts_resp = app
.post_json(
"/api/document-types",
&json!({"name": "Receipts"}),
Some(&token),
)
.await?;
assert_eq!(receipts_resp.status(), StatusCode::CREATED);
let receipts_body = body_to_vec(receipts_resp.into_body()).await?;
let receipts: DocumentTypeInfo = serde_json::from_slice(&receipts_body)?;
assert_eq!(invoices.name, "Invoices");
assert_eq!(receipts.name, "Receipts");
let doc_a = app
.upload_document(
"/api/documents",
"invoice-a.pdf",
"application/pdf",
b"invoice-a",
None,
&token,
)
.await?;
let doc_a_body = body_to_vec(doc_a.into_body()).await?;
let detail_a: DocumentDetail = serde_json::from_slice(&doc_a_body)?;
let doc_b = app
.upload_document(
"/api/documents",
"receipt-b.pdf",
"application/pdf",
b"receipt-b",
None,
&token,
)
.await?;
let doc_b_body = body_to_vec(doc_b.into_body()).await?;
let detail_b: DocumentDetail = serde_json::from_slice(&doc_b_body)?;
let doc_c = app
.upload_document(
"/api/documents",
"notes.txt",
"text/plain",
b"notes",
None,
&token,
)
.await?;
let doc_c_body = body_to_vec(doc_c.into_body()).await?;
let detail_c: DocumentDetail = serde_json::from_slice(&doc_c_body)?;
let assign_a = app
.patch_json(
&format!("/api/documents/{}", detail_a.document.id),
&json!({"document_type_id": invoices.id}),
Some(&token),
)
.await?;
assert_eq!(assign_a.status(), StatusCode::OK);
let assign_b = app
.patch_json(
&format!("/api/documents/{}", detail_b.document.id),
&json!({"document_type_id": receipts.id}),
Some(&token),
)
.await?;
assert_eq!(assign_b.status(), StatusCode::OK);
let invoices_resp = app
.get(
&format!("/api/documents?document_types={}", invoices.id),
Some(&token),
)
.await?;
assert!(invoices_resp.status().is_success());
let invoices_list_body = body_to_vec(invoices_resp.into_body()).await?;
let invoices_docs: Vec<DocumentListItem> = serde_json::from_slice(&invoices_list_body)?;
assert_eq!(invoices_docs.len(), 1);
assert_eq!(invoices_docs[0].id, detail_a.document.id);
assert_eq!(invoices_docs[0].document_type_id, Some(invoices.id));
let receipts_resp = app
.get(
&format!("/api/documents?document_types={}", receipts.id),
Some(&token),
)
.await?;
assert!(receipts_resp.status().is_success());
let receipts_list_body = body_to_vec(receipts_resp.into_body()).await?;
let receipts_docs: Vec<DocumentListItem> = serde_json::from_slice(&receipts_list_body)?;
assert_eq!(receipts_docs.len(), 1);
assert_eq!(receipts_docs[0].id, detail_b.document.id);
assert_eq!(receipts_docs[0].document_type_id, Some(receipts.id));
let combined_resp = app
.get(
&format!(
"/api/documents?document_types={},{}",
invoices.id, receipts.id
),
Some(&token),
)
.await?;
assert!(combined_resp.status().is_success());
let combined_body = body_to_vec(combined_resp.into_body()).await?;
let combined_docs: Vec<DocumentListItem> = serde_json::from_slice(&combined_body)?;
let combined_ids: Vec<Uuid> = combined_docs.iter().map(|doc| doc.id).collect();
assert!(combined_ids.contains(&detail_a.document.id));
assert!(combined_ids.contains(&detail_b.document.id));
assert!(!combined_ids.contains(&detail_c.document.id));
let refreshed_a = app
.get(
&format!("/api/documents/{}", detail_a.document.id),
Some(&token),
)
.await?;
assert!(refreshed_a.status().is_success());
let refreshed_a_body = body_to_vec(refreshed_a.into_body()).await?;
let refreshed_a_detail: DocumentDetail = serde_json::from_slice(&refreshed_a_body)?;
assert_eq!(
refreshed_a_detail.document.document_type_id,
Some(invoices.id)
);
let refreshed_b = app
.get(
&format!("/api/documents/{}", detail_b.document.id),
Some(&token),
)
.await?;
assert!(refreshed_b.status().is_success());
let refreshed_b_body = body_to_vec(refreshed_b.into_body()).await?;
let refreshed_b_detail: DocumentDetail = serde_json::from_slice(&refreshed_b_body)?;
assert_eq!(
refreshed_b_detail.document.document_type_id,
Some(receipts.id)
);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn restore_document_to_original_and_custom_folder() -> Result<()> {
let _lock = acquire_db_lock().await;