Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
281 lines
11 KiB
Go
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)
|
|
}
|