Backend: - auth/permissions.go: full permission matrix with RequirePermission/RequireRole middleware, CanEditCase, CanDeleteDocument helpers - auth/context.go: add user role to request context - auth/middleware.go: resolve role alongside tenant in auth flow - auth/tenant_resolver.go: verify membership + resolve role for X-Tenant-ID - handlers/case_assignments.go: CRUD for case-level user assignments - handlers/tenant_handler.go: UpdateMemberRole, GetMe (/api/me) endpoints - handlers/documents.go: permission-based delete (own vs all) - router/router.go: permission-wrapped routes for all endpoints - services/case_assignment_service.go: assign/unassign with tenant validation - services/tenant_service.go: UpdateMemberRole with owner protection - models/case_assignment.go: CaseAssignment model Database: - user_tenants.role: CHECK constraint (owner/partner/associate/paralegal/secretary) - case_assignments table: case_id, user_id, role (lead/team/viewer) - Migrated existing admin->partner, member->associate Frontend: - usePermissions hook: fetches /api/me, provides can() helper - TeamSettings: 5-role dropdown, role change, permission-gated invite - CaseAssignments: new component for case-level team management - Sidebar: conditionally hides AI/Settings based on permissions - Cases page: hides "Neue Akte" button for non-authorized roles - Case detail: new "Mitarbeiter" tab for assignment management
205 lines
5.0 KiB
Go
205 lines
5.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const maxUploadSize = 50 << 20 // 50 MB
|
|
|
|
type DocumentHandler struct {
|
|
svc *services.DocumentService
|
|
}
|
|
|
|
func NewDocumentHandler(svc *services.DocumentService) *DocumentHandler {
|
|
return &DocumentHandler{svc: svc}
|
|
}
|
|
|
|
func (h *DocumentHandler) ListByCase(w http.ResponseWriter, r *http.Request) {
|
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "missing tenant")
|
|
return
|
|
}
|
|
|
|
caseID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid case ID")
|
|
return
|
|
}
|
|
|
|
docs, err := h.svc.ListByCase(r.Context(), tenantID, caseID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"documents": docs,
|
|
"total": len(docs),
|
|
})
|
|
}
|
|
|
|
func (h *DocumentHandler) Upload(w http.ResponseWriter, r *http.Request) {
|
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "missing tenant")
|
|
return
|
|
}
|
|
userID, _ := auth.UserFromContext(r.Context())
|
|
|
|
caseID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid case ID")
|
|
return
|
|
}
|
|
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
|
|
if err := r.ParseMultipartForm(maxUploadSize); err != nil {
|
|
writeError(w, http.StatusBadRequest, "file too large or invalid multipart form")
|
|
return
|
|
}
|
|
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "missing file field")
|
|
return
|
|
}
|
|
defer file.Close()
|
|
|
|
title := r.FormValue("title")
|
|
if title == "" {
|
|
title = header.Filename
|
|
}
|
|
|
|
contentType := header.Header.Get("Content-Type")
|
|
if contentType == "" {
|
|
contentType = "application/octet-stream"
|
|
}
|
|
|
|
input := services.CreateDocumentInput{
|
|
Title: title,
|
|
DocType: r.FormValue("doc_type"),
|
|
Filename: header.Filename,
|
|
ContentType: contentType,
|
|
Size: int(header.Size),
|
|
Data: file,
|
|
}
|
|
|
|
doc, err := h.svc.Create(r.Context(), tenantID, caseID, userID, input)
|
|
if err != nil {
|
|
if err.Error() == "case not found" {
|
|
writeError(w, http.StatusNotFound, "case not found")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, doc)
|
|
}
|
|
|
|
func (h *DocumentHandler) Download(w http.ResponseWriter, r *http.Request) {
|
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "missing tenant")
|
|
return
|
|
}
|
|
|
|
docID, err := uuid.Parse(r.PathValue("docId"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid document ID")
|
|
return
|
|
}
|
|
|
|
body, contentType, title, err := h.svc.Download(r.Context(), tenantID, docID)
|
|
if err != nil {
|
|
if err.Error() == "document not found" || err.Error() == "document has no file" {
|
|
writeError(w, http.StatusNotFound, err.Error())
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
defer body.Close()
|
|
|
|
w.Header().Set("Content-Type", contentType)
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, title))
|
|
io.Copy(w, body)
|
|
}
|
|
|
|
func (h *DocumentHandler) GetMeta(w http.ResponseWriter, r *http.Request) {
|
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "missing tenant")
|
|
return
|
|
}
|
|
|
|
docID, err := uuid.Parse(r.PathValue("docId"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid document ID")
|
|
return
|
|
}
|
|
|
|
doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if doc == nil {
|
|
writeError(w, http.StatusNotFound, "document not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, doc)
|
|
}
|
|
|
|
func (h *DocumentHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "missing tenant")
|
|
return
|
|
}
|
|
userID, _ := auth.UserFromContext(r.Context())
|
|
role := auth.UserRoleFromContext(r.Context())
|
|
|
|
docID, err := uuid.Parse(r.PathValue("docId"))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid document ID")
|
|
return
|
|
}
|
|
|
|
// Check permission: owner/partner can delete any, associate can delete own
|
|
doc, err := h.svc.GetByID(r.Context(), tenantID, docID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
if doc == nil {
|
|
writeError(w, http.StatusNotFound, "document not found")
|
|
return
|
|
}
|
|
|
|
uploaderID := uuid.Nil
|
|
if doc.UploadedBy != nil {
|
|
uploaderID = *doc.UploadedBy
|
|
}
|
|
if !auth.CanDeleteDocument(role, uploaderID, userID) {
|
|
writeError(w, http.StatusForbidden, "insufficient permissions to delete this document")
|
|
return
|
|
}
|
|
|
|
if err := h.svc.Delete(r.Context(), tenantID, docID, userID); err != nil {
|
|
writeError(w, http.StatusNotFound, "document not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|