Files
patrick 9a24ea29e1 FDN-01: repository & projektgerüst
Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
2026-08-11 21:27:53 +02:00

281 lines
11 KiB
Go

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)
}