Adds GET /api/projects/{id}/export?direct_only=0|1 streaming a
deterministic project-subtree bundle in the same xlsx + JSON + per-sheet
CSV shape as Slice 1's personal export. 16 entity sheets per design §2:
projects + project_teams + project_partner_units + deadlines +
appointments + parties + notes (4-way polymorphism resolved) + documents
(metadata only) + project_events + approval_requests + approval_policies
(triple-source attribution with `source` column for Q4 lock-in) +
checklist_instances + partner_units (attached only) +
partner_unit_members (members of attached units only) + users_referenced
(FK-referenced users only) + system_audit_log_subset. Personal sidecars
explicitly excluded; reference sheets (proceeding_types, event_types,
deadline_rules, courts, …) ship for standalone interpretability.
§4 permission gate enforced server-side:
- global_admin can export anything, OR
- direct project_teams membership with responsibility ∈ {lead, member}
- Observers + Externals + derived-only partner-unit users → 403
bilingual ("Datenexport ist nur Team-Mitgliedern (Lead / Member)
vorbehalten / Data export is restricted to project team members").
Cross-subtree FK detection (Q3 lock-in: keep + warn) runs one
lightweight SELECT against projects.counterclaim_of and appends one
warning row to __meta.warnings per outbound reference. Recipients can
choose to keep or strip the FK on re-import.
Filename includes 8-hex-char short-uuid disambiguator (Q5 lock-in):
paliad-export-project-<slug>-<short-uuid>-<ts>.zip — two projects with
identical titles produce different filenames even when archived
together.
Audit row in paliad.system_audit_log (no new migration — already
supports scope='project'): metadata carries root_label + root_path
(ltree) + direct_only flag (Q6 lock-in) so the audit row remains
interpretable after the project is deleted.
__meta sheet + README.txt extended to surface project-scope fields:
scope_root_label, scope_root_path, direct_only.
ExportFilename signature extended to take a rootID; Slice 1 callsite
updated to pass uuid.Nil.
8 new pure-function tests pin: sheet registry shape (24 sheets in
order), triple-source approval_policies SQL tags, direct_only narrows
subtree to root-only, no-personal-sidecars guard, attached-only
partner_units filter, shortUUIDSuffix shape, project-scope meta rows,
short-uuid filename collision avoidance.
291 lines
9.7 KiB
Go
291 lines
9.7 KiB
Go
package handlers
|
|
|
|
// Data-export handlers (t-paliad-214).
|
|
//
|
|
// Slice 1: personal scope
|
|
// GET /api/me/export → streams a personal-scope export .zip
|
|
//
|
|
// Slice 2: project subtree scope
|
|
// GET /api/projects/{id}/export?direct_only=0|1 → streams a project-subtree
|
|
// export .zip
|
|
//
|
|
// Slice 3 (org, async) lands in a follow-up.
|
|
//
|
|
// Authentication: the existing protected mux middleware (auth.Middleware +
|
|
// auth.WithUserID) populates the user UUID in the context. Slice 1 gates
|
|
// only on authentication; Slice 2 adds a §4 responsibility + global_admin
|
|
// check via handleProjectExportGate.
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// exportRequestTimeout caps any single export request. Personal-scope
|
|
// exports at firm-scale data shape complete in well under this; the
|
|
// timeout is the watchdog that surfaces "too large for sync" loudly
|
|
// (the user gets a 503 and slice 3's async path becomes the answer).
|
|
const exportRequestTimeout = 30 * time.Second
|
|
|
|
// handleMeExport streams the caller's personal-scope export .zip.
|
|
//
|
|
// Order of operations:
|
|
//
|
|
// 1. Validate auth + db wiring.
|
|
// 2. Look up the caller's user row for actor_email / actor_label.
|
|
// 3. Write an audit row (event_type='data_export', scope='personal').
|
|
// 4. Run the export into an in-memory buffer (so we can patch the
|
|
// audit row with file_size_bytes before flushing to the client).
|
|
// 5. Set headers + flush.
|
|
// 6. Patch the audit row with success (row_counts + file_size).
|
|
// On any error after step 3, the audit row is patched as failed.
|
|
func handleMeExport(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.export == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "export service not configured",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Apply the per-request watchdog.
|
|
ctx, cancel := context.WithTimeout(r.Context(), exportRequestTimeout)
|
|
defer cancel()
|
|
|
|
user, err := dbSvc.users.GetByID(ctx, uid)
|
|
if err != nil || user == nil {
|
|
log.Printf("export: user lookup failed for %s: %v", uid, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "user lookup failed",
|
|
})
|
|
return
|
|
}
|
|
spec := services.ExportSpec{
|
|
Scope: services.ExportScopePersonal,
|
|
ActorID: uid,
|
|
ActorEmail: user.Email,
|
|
ActorLabel: user.DisplayName,
|
|
GeneratedAt: time.Now().UTC(),
|
|
}
|
|
|
|
auditID, err := dbSvc.export.WriteAuditRow(ctx, spec)
|
|
if err != nil {
|
|
log.Printf("export: audit insert failed for %s: %v", uid, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "audit write failed",
|
|
})
|
|
return
|
|
}
|
|
|
|
// Generate into a memory buffer so we can size + audit-patch BEFORE
|
|
// writing to the response (otherwise headers are committed and we
|
|
// can't return a 500 if anything fails). At personal scale this is a
|
|
// sub-megabyte buffer.
|
|
var buf bytes.Buffer
|
|
meta, err := dbSvc.export.WritePersonal(ctx, &buf, spec)
|
|
if err != nil {
|
|
dbSvc.export.PatchAuditRowFailure(context.Background(), auditID, err.Error())
|
|
log.Printf("export: WritePersonal failed for %s (audit=%s): %v", uid, auditID, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "export generation failed",
|
|
})
|
|
return
|
|
}
|
|
|
|
filename := services.ExportFilename(services.ExportScopePersonal, "", uuid.Nil, spec.GeneratedAt)
|
|
size := int64(buf.Len())
|
|
|
|
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
|
|
// Audit-patch failure isn't fatal to the user — they still get
|
|
// their export. Log it; the data already left the system.
|
|
log.Printf("export: audit patch failed for %s (audit=%s): %v", uid, auditID, err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
|
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
|
w.Header().Set("X-Paliad-Export-Audit-Id", auditID.String())
|
|
if _, err := w.Write(buf.Bytes()); err != nil {
|
|
// Connection dropped mid-flush — the user didn't get the file.
|
|
// We don't patch the audit row a second time; the success patch
|
|
// already recorded the row counts. A separate event would be
|
|
// noise (the failure is at the network layer, not in our path).
|
|
log.Printf("export: response write failed for %s (audit=%s): %v", uid, auditID, err)
|
|
}
|
|
}
|
|
|
|
// handleProjectExport streams the project-subtree export .zip for the
|
|
// project named in the URL path.
|
|
//
|
|
// Authorization (Slice 2 §4):
|
|
//
|
|
// - caller must be authenticated (handled by the mux middleware),
|
|
// - caller must pass paliad.can_see_project(rootID) — enforced via
|
|
// ProjectService.GetByID returning ErrNotVisible → 404,
|
|
// - caller must be on paliad.project_teams for the root with
|
|
// responsibility ∈ {lead, member}, OR be a global_admin.
|
|
// Observers + Externals see but cannot extract — 403 bilingual.
|
|
//
|
|
// Query params:
|
|
// - ?direct_only=1 narrows the export to the root project only (no
|
|
// descendants). Default = subtree-inclusive.
|
|
func handleProjectExport(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
if dbSvc.export == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "export service not configured",
|
|
})
|
|
return
|
|
}
|
|
rootID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "invalid project id",
|
|
})
|
|
return
|
|
}
|
|
|
|
directOnly := false
|
|
if q := r.URL.Query().Get("direct_only"); q == "1" || q == "true" {
|
|
directOnly = true
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), exportRequestTimeout)
|
|
defer cancel()
|
|
|
|
// Visibility gate (a + b): GetByID returns ErrNotVisible when the
|
|
// caller can't see the project, which we map to 404. The handler
|
|
// stays oblivious to whether the project doesn't exist or simply
|
|
// isn't visible — that's by design (RLS-style opacity).
|
|
project, err := dbSvc.projects.GetByID(ctx, uid, rootID)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
|
|
// Authority gate (c): direct-team responsibility ∈ {lead, member} OR
|
|
// global_admin. Derived-only-via-partner-unit users (DerivedPeer)
|
|
// don't qualify for extraction — m's Q1 lock-in.
|
|
allowed, err := callerCanExportProject(ctx, uid, rootID)
|
|
if err != nil {
|
|
log.Printf("export: authority check failed for user=%s project=%s: %v", uid, rootID, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "authority check failed",
|
|
})
|
|
return
|
|
}
|
|
if !allowed {
|
|
// Bilingual 403 per Q7. Pattern matches mapApprovalError style.
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
"code": "export_not_authorized",
|
|
"message": "Datenexport ist nur Team-Mitgliedern (Lead / Member) vorbehalten. / Data export is restricted to project team members (lead / member).",
|
|
})
|
|
return
|
|
}
|
|
|
|
user, err := dbSvc.users.GetByID(ctx, uid)
|
|
if err != nil || user == nil {
|
|
log.Printf("export: user lookup failed for %s: %v", uid, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "user lookup failed",
|
|
})
|
|
return
|
|
}
|
|
|
|
spec := services.ExportSpec{
|
|
Scope: services.ExportScopeProject,
|
|
ScopeRoot: &rootID,
|
|
ScopeRootLabel: project.Title,
|
|
ScopeRootPath: project.Path,
|
|
DirectOnly: directOnly,
|
|
ActorID: uid,
|
|
ActorEmail: user.Email,
|
|
ActorLabel: user.DisplayName,
|
|
GeneratedAt: time.Now().UTC(),
|
|
}
|
|
|
|
auditID, err := dbSvc.export.WriteAuditRow(ctx, spec)
|
|
if err != nil {
|
|
log.Printf("export: audit insert failed for %s/project=%s: %v", uid, rootID, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "audit write failed",
|
|
})
|
|
return
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
meta, err := dbSvc.export.WriteProject(ctx, &buf, spec)
|
|
if err != nil {
|
|
dbSvc.export.PatchAuditRowFailure(context.Background(), auditID, err.Error())
|
|
log.Printf("export: WriteProject failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
|
"error": "export generation failed",
|
|
})
|
|
return
|
|
}
|
|
|
|
filename := services.ExportFilename(services.ExportScopeProject, project.Title, rootID, spec.GeneratedAt)
|
|
size := int64(buf.Len())
|
|
|
|
if err := dbSvc.export.PatchAuditRowSuccess(ctx, auditID, meta, size); err != nil {
|
|
log.Printf("export: audit patch failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/zip")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
|
|
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
|
|
w.Header().Set("X-Paliad-Export-Audit-Id", auditID.String())
|
|
if _, err := w.Write(buf.Bytes()); err != nil {
|
|
log.Printf("export: response write failed for %s/project=%s (audit=%s): %v", uid, rootID, auditID, err)
|
|
}
|
|
}
|
|
|
|
// callerCanExportProject is the §4 authority check:
|
|
//
|
|
// - global_admin can extract anything anywhere.
|
|
// - else: caller must be on paliad.project_teams for the root with
|
|
// responsibility ∈ {lead, member}.
|
|
//
|
|
// One query, parameterised; returns the boolean. Errors surface to the
|
|
// handler as 500.
|
|
func callerCanExportProject(ctx context.Context, userID, projectID uuid.UUID) (bool, error) {
|
|
const q = `
|
|
SELECT
|
|
EXISTS (
|
|
SELECT 1 FROM paliad.users u
|
|
WHERE u.id = $1 AND u.global_role = 'global_admin'
|
|
) OR EXISTS (
|
|
SELECT 1 FROM paliad.project_teams pt
|
|
WHERE pt.user_id = $1
|
|
AND pt.project_id = $2
|
|
AND pt.responsibility IN ('lead', 'member')
|
|
)
|
|
`
|
|
var ok bool
|
|
if err := dbSvc.projects.DB().QueryRowContext(ctx, q, userID, projectID).Scan(&ok); err != nil {
|
|
return false, err
|
|
}
|
|
return ok, nil
|
|
}
|