package api import ( "encoding/json" "errors" "net/http" "strconv" "strings" "archivdms/internal/audit" "archivdms/internal/auth" "archivdms/internal/storage" "archivdms/internal/userstore" ) // akteACLUserID returns the ACL user filter for the caller: nil for // domain_admin/superadmin (see every document in the tenant), a non-nil user ID // for role 'user' (filtered against document_visibility). Mirrors the logic in // handleListDocuments. func akteACLUserID(sess *auth.Session) *int64 { if auth.HasRole(sess.Role, userstore.RoleDomainAdmin) { return nil } uid := sess.UserID return &uid } func (s *Server) handleListAkten(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } akten, err := s.store.ListAkten(r.Context(), *sess.TenantID) if err != nil { writeError(w, http.StatusInternalServerError, "list akten failed") return } writeJSON(w, http.StatusOK, akten) } type createAkteRequest struct { Titel string `json:"titel"` Beschreibung string `json:"beschreibung"` CorrespondentID *int64 `json:"correspondent_id"` } func (s *Server) handleCreateAkte(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) ctx := r.Context() if sess.TenantID == nil { writeError(w, http.StatusForbidden, "tenant context required") return } var req createAkteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } titel := strings.TrimSpace(req.Titel) if titel == "" { writeError(w, http.StatusBadRequest, "titel is required") return } // Guard against cross-tenant references: the correspondent must belong to // the same tenant. if req.CorrespondentID != nil && !s.taxonomyEntityBelongsToTenant(ctx, "correspondents", *req.CorrespondentID, *sess.TenantID) { s.audlog.Log(audit.Entry{EventType: audit.EventAkteCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: "invalid_correspondent_id"}) writeError(w, http.StatusBadRequest, "invalid correspondent_id") return } akte, err := s.store.CreateAkte(ctx, *sess.TenantID, titel, strings.TrimSpace(req.Beschreibung), req.CorrespondentID, sess.UserID) if err != nil { s.audlog.Log(audit.Entry{EventType: audit.EventAkteCreate, Username: sess.Username, TenantID: sess.TenantID, Success: false, Detail: err.Error()}) writeError(w, http.StatusInternalServerError, "create akte failed") return } s.audlog.Log(audit.Entry{EventType: audit.EventAkteCreate, Username: sess.Username, TenantID: sess.TenantID, Success: true, Detail: "akte_id=" + strconv.FormatInt(akte.ID, 10)}) writeJSON(w, http.StatusCreated, akte) } // akteDetailResponse is the GET /api/akten/{id} payload: the akte plus its // ACL-filtered documents. type akteDetailResponse struct { *storage.Akte Documents []storage.Document `json:"documents"` } func (s *Server) handleGetAkte(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) ctx := r.Context() id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || sess.TenantID == nil { writeError(w, http.StatusBadRequest, "invalid akte id") return } akte, err := s.store.GetAkte(ctx, id, *sess.TenantID) if err != nil { writeError(w, http.StatusNotFound, "akte not found") return } docs, err := s.store.ListAkteDocuments(ctx, id, *sess.TenantID, akteACLUserID(sess)) if err != nil { writeError(w, http.StatusInternalServerError, "list akte documents failed") return } writeJSON(w, http.StatusOK, akteDetailResponse{Akte: akte, Documents: docs}) } type updateAkteRequest struct { Titel string `json:"titel"` Beschreibung string `json:"beschreibung"` CorrespondentID *int64 `json:"correspondent_id"` } func (s *Server) handleUpdateAkte(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) ctx := r.Context() id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || sess.TenantID == nil { writeError(w, http.StatusBadRequest, "invalid akte id") return } var req updateAkteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } titel := strings.TrimSpace(req.Titel) if titel == "" { writeError(w, http.StatusBadRequest, "titel is required") return } if req.CorrespondentID != nil && !s.taxonomyEntityBelongsToTenant(ctx, "correspondents", *req.CorrespondentID, *sess.TenantID) { s.audlog.Log(audit.Entry{EventType: audit.EventAkteUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "invalid_correspondent_id"}) writeError(w, http.StatusBadRequest, "invalid correspondent_id") return } if err := s.store.UpdateAkte(ctx, id, *sess.TenantID, titel, strings.TrimSpace(req.Beschreibung), req.CorrespondentID); err != nil { status := http.StatusInternalServerError msg := "update akte failed" if errors.Is(err, storage.ErrAkteNotFound) { status = http.StatusNotFound msg = "akte not found" } s.audlog.Log(audit.Entry{EventType: audit.EventAkteUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()}) writeError(w, status, msg) return } akte, err := s.store.GetAkte(ctx, id, *sess.TenantID) if err != nil { writeError(w, http.StatusNotFound, "akte not found") return } s.audlog.Log(audit.Entry{EventType: audit.EventAkteUpdate, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true}) writeJSON(w, http.StatusOK, akte) } func (s *Server) handleCloseAkte(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) ctx := r.Context() id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || sess.TenantID == nil { writeError(w, http.StatusBadRequest, "invalid akte id") return } if err := s.store.CloseAkte(ctx, id, *sess.TenantID); err != nil { status := http.StatusInternalServerError msg := "close akte failed" if errors.Is(err, storage.ErrAkteNotFound) { status = http.StatusNotFound msg = "akte not found" } s.audlog.Log(audit.Entry{EventType: audit.EventAkteClose, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()}) writeError(w, status, msg) return } akte, err := s.store.GetAkte(ctx, id, *sess.TenantID) if err != nil { writeError(w, http.StatusNotFound, "akte not found") return } s.audlog.Log(audit.Entry{EventType: audit.EventAkteClose, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true}) writeJSON(w, http.StatusOK, akte) } func (s *Server) handleDeleteAkte(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) ctx := r.Context() id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || sess.TenantID == nil { writeError(w, http.StatusBadRequest, "invalid akte id") return } if err := s.store.DeleteAkte(ctx, id, *sess.TenantID); err != nil { status := http.StatusInternalServerError msg := "delete akte failed" if errors.Is(err, storage.ErrAkteNotFound) { status = http.StatusNotFound msg = "akte not found" } s.audlog.Log(audit.Entry{EventType: audit.EventAkteDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()}) writeError(w, status, msg) return } s.audlog.Log(audit.Entry{EventType: audit.EventAkteDelete, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // setDocumentAkteRequest uses a pointer so an explicit JSON null removes the // assignment while an omitted field is rejected (see handleSetDocumentAkte). type setDocumentAkteRequest struct { AkteID *int64 `json:"akte_id"` } // handleSetDocumentAkte assigns (or clears) a document's akte membership // (PUT /api/documents/{id}/akte, body {"akte_id": number|null}). A null value // removes the assignment. The akte is not part of the document ACL, so // SetDocumentAkte only re-syncs the search index. Audited as // EventAkteDocumentAdd (assign) or EventAkteDocumentRemove (clear). func (s *Server) handleSetDocumentAkte(w http.ResponseWriter, r *http.Request) { sess := sessionFromCtx(r.Context()) ctx := r.Context() id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil || sess.TenantID == nil { writeError(w, http.StatusBadRequest, "invalid document id") return } var req setDocumentAkteRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") return } event := audit.EventAkteDocumentRemove if req.AkteID != nil { event = audit.EventAkteDocumentAdd } // Ownership check: the document must belong to the caller's tenant. if _, err := s.store.GetDocument(ctx, id, *sess.TenantID); err != nil { s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "document_not_found"}) writeError(w, http.StatusNotFound, "document not found") return } // Guard against cross-tenant references: the akte must belong to the same // tenant (nil means "remove", which needs no lookup). if req.AkteID != nil { if _, err := s.store.GetAkte(ctx, *req.AkteID, *sess.TenantID); err != nil { s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: "invalid_akte_id"}) writeError(w, http.StatusBadRequest, "invalid akte_id") return } } if err := s.store.SetDocumentAkte(ctx, id, *sess.TenantID, req.AkteID); err != nil { status := http.StatusInternalServerError msg := "set akte failed" if errors.Is(err, storage.ErrDocumentNotFound) { status = http.StatusNotFound msg = "document not found" } s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: false, Detail: err.Error()}) writeError(w, status, msg) return } doc, err := s.store.GetDocument(ctx, id, *sess.TenantID) if err != nil { writeError(w, http.StatusNotFound, "document not found") return } detail := "akte_id=cleared" if req.AkteID != nil { detail = "akte_id=" + strconv.FormatInt(*req.AkteID, 10) } s.audlog.Log(audit.Entry{EventType: event, Username: sess.Username, TenantID: sess.TenantID, DocumentID: r.PathValue("id"), Success: true, Detail: detail}) writeJSON(w, http.StatusOK, doc) }