Three additions on top of Slice B's edit-mode chrome. **Catalog expansion (2 new widgets, default-hidden — opt-in via picker):** - pinned-projects: surfaces a list of the user's pinned matters via the pre-existing PinService (mig 062/063, pre-dates t-paliad-219). New DashboardService.loadPinnedProjects joins paliad.user_pinned_projects to paliad.projects under the standard visibility predicate, preserves pinned-at-DESC order, capped at PinnedProjectsCap=20. PinnedProjects []PinnedProjectRef grows DashboardData; SetPinService wired post-construction to mirror the SetApprovalService pattern. - quick-actions: pure UI affordance with three buttons linking to the existing /projects/new, /deadlines/new, /appointments/new routes. No backend payload, no settings schema. Both default-hidden — m's brief asked for "high-value adds"; injecting new widgets into every user's dashboard unannounced would be loud. Factory test relaxed: visibility now matches catalog.DefaultVisible instead of the previous "all-visible" invariant. **Firm-wide admin default (mig 117 + new service + 4 endpoints):** - paliad.firm_dashboard_default: single-row table (id smallint PK CHECK id=1) with layout_json + updated_by + updated_at. RLS: SELECT authenticated, no INSERT/UPDATE policy (writes go through the service-role connection behind the adminGate). - FirmDashboardDefaultService Get/Set/Clear. Validates against the catalog on Set so an admin can't seed an invalid layout. - DashboardLayoutService.SetFirmDefaultService wires in the firm source. Both GetOrSeed and ResetToDefault now prefer the firm default over the code-resident FactoryDefaultLayout when one is set. Nil-safe — empty firm row falls back to the factory layout, transient DB errors fall back too (a blip can't strand a user without a dashboard). - HTTP: GET / PUT / DELETE /api/admin/firm-dashboard-default (admin- gated). POST /api/me/dashboard-layout/promote: admin convenience — reads the admin's own current layout and stashes it as the firm default (saves the JSON-editor step; admins edit via /dashboard's normal editor, then click Promote). **Frontend (Slice B's edit-mode footer grew an admin button):** - "Als Firmen-Standard speichern" button in the edit footer; hidden via CSS-inline until syncPromoteButtonVisibility unhides for global_admin. Confirm() → POST /promote → toast. - The existing "Auf Standard zurücksetzen" copy stays the same — the semantics now "firm default if set, else factory", which is the desired surface: users see one canonical "Standard" link. i18n: 13 new keys × DE+EN (dashboard.pinned.*, dashboard.quick.*, dashboard.edit.promote*). i18n-keys.ts regenerated by build. m/paliad#46. go build ./... clean; go vet ./... clean go test ./internal/... clean (Slice C catalog test + factory-default test relaxation; FirmDashboardDefault round-trip tests gated on TEST_DATABASE_URL) Migration 117 dry-run: PASS (other dry-run failures are pre-existing local-DB collisions on origin/main; mig 117 itself clean) bun run build clean: dashboard.html carries new section markup + admin button; dashboard.js bundles renderPinnedProjects + promote handler + all new i18n keys
714 lines
21 KiB
Go
714 lines
21 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/auth"
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
// dbServices bundles the Phase B services so handlers can stay thin.
|
|
// Nil if DATABASE_URL was unset at startup.
|
|
type dbServices struct {
|
|
projects *services.ProjectService
|
|
team *services.TeamService
|
|
partnerUnit *services.PartnerUnitService
|
|
parties *services.PartyService
|
|
deadline *services.DeadlineService
|
|
appointment *services.AppointmentService
|
|
caldav *services.CalDAVService
|
|
caldavBindings *services.CalendarBindingService
|
|
rules *services.DeadlineRuleService
|
|
calc *services.DeadlineCalculator
|
|
users *services.UserService
|
|
fristenrechner *services.FristenrechnerService
|
|
eventDeadline *services.EventDeadlineService
|
|
eventTrigger *services.EventTriggerService
|
|
ruleEditor *services.RuleEditorService
|
|
deadlineSearch *services.DeadlineSearchService
|
|
eventCategory *services.EventCategoryService
|
|
eventType *services.EventTypeService
|
|
dashboard *services.DashboardService
|
|
note *services.NoteService
|
|
checklistInst *services.ChecklistInstanceService
|
|
checklistCatalog *services.ChecklistCatalogService
|
|
checklistTemplate *services.ChecklistTemplateService
|
|
checklistShare *services.ChecklistShareService
|
|
checklistPromotion *services.ChecklistPromotionService
|
|
mail *services.MailService
|
|
invite *services.InviteService
|
|
agenda *services.AgendaService
|
|
audit *services.AuditService
|
|
emailTemplate *services.EmailTemplateService
|
|
link *services.LinkService
|
|
event *services.EventService
|
|
courts *services.CourtService
|
|
approval *services.ApprovalService
|
|
derivation *services.DerivationService
|
|
userView *services.UserViewService
|
|
broadcast *services.BroadcastService
|
|
pin *services.PinService
|
|
cardLayout *services.CardLayoutService
|
|
dashboardLayout *services.DashboardLayoutService
|
|
firmDashboardDefault *services.FirmDashboardDefaultService
|
|
projection *services.ProjectionService
|
|
export *services.ExportService
|
|
}
|
|
|
|
var dbSvc *dbServices
|
|
|
|
// requireDB returns true if the DB-backed services are wired; otherwise
|
|
// writes a 503 response and returns false.
|
|
func requireDB(w http.ResponseWriter) bool {
|
|
if dbSvc == nil {
|
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
|
"error": "database not configured — set DATABASE_URL on the server",
|
|
})
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// requireUser pulls the authenticated user UUID from the request context.
|
|
func requireUser(w http.ResponseWriter, r *http.Request) (uuid.UUID, bool) {
|
|
uid, ok := auth.UserIDFromContext(r.Context())
|
|
if !ok {
|
|
writeJSON(w, http.StatusUnauthorized, map[string]string{
|
|
"error": "authentication required",
|
|
})
|
|
return uuid.Nil, false
|
|
}
|
|
return uid, true
|
|
}
|
|
|
|
// writeServiceError maps a services error to an HTTP status.
|
|
func writeServiceError(w http.ResponseWriter, err error) {
|
|
if mapApprovalError(w, err) {
|
|
return
|
|
}
|
|
switch {
|
|
case errors.Is(err, services.ErrNotVisible):
|
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
|
|
case errors.Is(err, services.ErrForbidden):
|
|
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
|
case errors.Is(err, services.ErrInvalidInput):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
|
case errors.Is(err, services.ErrInvalidProceedingTypeCategory):
|
|
// Phase 3 Slice 5 (t-paliad-186). Bilingual user-facing message
|
|
// matches what the project-form copy expects so the toast reads
|
|
// naturally without an i18n round-trip in the handler.
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"error": "Verfahrenstyp muss ein Fristenrechner-Typ sein / proceeding type must be a Fristenrechner type",
|
|
})
|
|
case errors.Is(err, services.ErrEventTypeSlugTaken):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
case errors.Is(err, services.ErrLastProjectAdmin):
|
|
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
|
default:
|
|
log.Printf("ERROR service: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
}
|
|
}
|
|
|
|
// mapApprovalError handles approval-flow errors that bubble through the
|
|
// shared writeServiceError path (entity mutation handlers — deadlines,
|
|
// appointments — go through writeServiceError, not writeApprovalError).
|
|
//
|
|
// Returns true iff err matched an approval-flow case and the response
|
|
// has been written. False = caller should keep walking the switch.
|
|
//
|
|
// Response shape (t-paliad-160 §B):
|
|
//
|
|
// {
|
|
// code: "awaiting_approval" | "no_qualified_approver" | ...,
|
|
// message: "<localizable German hint>",
|
|
// request_id?: "<uuid>", // present when known
|
|
// required_role?: "<role>", // present when known
|
|
// }
|
|
func mapApprovalError(w http.ResponseWriter, err error) bool {
|
|
var pendingErr *services.PendingApprovalError
|
|
if errors.As(err, &pendingErr) {
|
|
body := map[string]string{
|
|
"code": "awaiting_approval",
|
|
"message": "Diese Anforderung wartet auf Genehmigung.",
|
|
}
|
|
if pendingErr.RequestID != "" {
|
|
body["request_id"] = pendingErr.RequestID
|
|
}
|
|
if pendingErr.RequiredRole != "" {
|
|
body["required_role"] = pendingErr.RequiredRole
|
|
}
|
|
writeJSON(w, http.StatusConflict, body)
|
|
return true
|
|
}
|
|
switch {
|
|
case errors.Is(err, services.ErrConcurrentPending):
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"code": "awaiting_approval",
|
|
"message": "Diese Anforderung wartet auf Genehmigung.",
|
|
})
|
|
return true
|
|
case errors.Is(err, services.ErrNoQualifiedApprover):
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"code": "no_qualified_approver",
|
|
"message": "Es gibt keinen anderen Benutzer, der diese Anfrage genehmigen kann.",
|
|
})
|
|
return true
|
|
case errors.Is(err, services.ErrSelfApproval):
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
"code": "self_approval_blocked",
|
|
"message": "Selbst-Genehmigung ist nicht erlaubt.",
|
|
})
|
|
return true
|
|
case errors.Is(err, services.ErrNotApprover):
|
|
writeJSON(w, http.StatusForbidden, map[string]string{
|
|
"code": "not_authorized",
|
|
"message": "Sie sind für diese Genehmigung nicht berechtigt.",
|
|
})
|
|
return true
|
|
case errors.Is(err, services.ErrRequestNotPending):
|
|
writeJSON(w, http.StatusConflict, map[string]string{
|
|
"code": "request_not_pending",
|
|
"message": "Die Anfrage ist nicht mehr offen.",
|
|
})
|
|
return true
|
|
case errors.Is(err, services.ErrSuggestionRequiresChange):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"code": "suggestion_requires_change",
|
|
"message": "Ein Vorschlag braucht entweder geänderte Werte oder einen Kommentar.",
|
|
})
|
|
return true
|
|
case errors.Is(err, services.ErrSuggestionLifecycleInvalid):
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
|
"code": "suggestion_lifecycle_invalid",
|
|
"message": "Änderungen vorschlagen ist nur für Update- und Complete-Anfragen möglich.",
|
|
})
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GET /api/projects — list visible projects.
|
|
// Query params: ?type=case&status=active&parent_id=<uuid>&parent_null=1&search=foo
|
|
func handleListProjects(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
filter := services.ProjectFilter{
|
|
Type: q.Get("type"),
|
|
Status: q.Get("status"),
|
|
Search: q.Get("search"),
|
|
}
|
|
if pidStr := q.Get("parent_id"); pidStr != "" {
|
|
pid, err := uuid.Parse(pidStr)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid parent_id"})
|
|
return
|
|
}
|
|
filter.ParentID = &pid
|
|
}
|
|
if q.Get("parent_null") == "1" || q.Get("parent_null") == "true" {
|
|
filter.ParentNullOnly = true
|
|
}
|
|
rows, err := dbSvc.projects.List(r.Context(), uid, filter)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// POST /api/projects — also accepts the legacy POST /api/akten body shape
|
|
// ({aktenzeichen, owning_office, court_ref}) for the frontend transition.
|
|
// aktenzeichen → reference, court_ref → case_number, owning_office is dropped
|
|
// (no longer part of the visibility model). Type defaults to 'case'.
|
|
func handleCreateProject(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
// Parse into a loose map so we can accept both old and new shapes.
|
|
var raw map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
input := services.CreateProjectInput{
|
|
Type: services.ProjectTypeCase,
|
|
}
|
|
if v, ok := raw["type"].(string); ok && v != "" {
|
|
input.Type = v
|
|
}
|
|
if v, ok := raw["title"].(string); ok {
|
|
input.Title = v
|
|
}
|
|
// Legacy aktenzeichen → reference; new shape uses reference directly.
|
|
if v, ok := raw["reference"].(string); ok && v != "" {
|
|
input.Reference = &v
|
|
} else if v, ok := raw["aktenzeichen"].(string); ok && v != "" {
|
|
input.Reference = &v
|
|
}
|
|
if v, ok := raw["description"].(string); ok && v != "" {
|
|
input.Description = &v
|
|
}
|
|
if v, ok := raw["status"].(string); ok {
|
|
input.Status = v
|
|
}
|
|
if v, ok := raw["court"].(string); ok && v != "" {
|
|
input.Court = &v
|
|
}
|
|
if v, ok := raw["case_number"].(string); ok && v != "" {
|
|
input.CaseNumber = &v
|
|
} else if v, ok := raw["court_ref"].(string); ok && v != "" {
|
|
input.CaseNumber = &v
|
|
}
|
|
if v, ok := raw["parent_id"].(string); ok && v != "" {
|
|
pid, err := uuid.Parse(v)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid parent_id"})
|
|
return
|
|
}
|
|
input.ParentID = &pid
|
|
}
|
|
if v, ok := raw["client_number"].(string); ok && v != "" {
|
|
input.ClientNumber = &v
|
|
}
|
|
if v, ok := raw["matter_number"].(string); ok && v != "" {
|
|
input.MatterNumber = &v
|
|
}
|
|
if v, ok := raw["netdocuments_url"].(string); ok && v != "" {
|
|
input.NetDocumentsURL = &v
|
|
}
|
|
if v, ok := raw["instance_level"].(string); ok {
|
|
// Empty string is the explicit "clear" sentinel for the
|
|
// service layer (nullableInstanceLevel writes NULL).
|
|
input.InstanceLevel = &v
|
|
}
|
|
p, err := dbSvc.projects.Create(r.Context(), uid, input)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, p)
|
|
}
|
|
|
|
// GET /api/projects/{id}
|
|
func handleGetProject(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
p, err := dbSvc.projects.GetByID(r.Context(), uid, id)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
// t-paliad-223: piggyback effective_project_admin onto the project
|
|
// payload so the frontend can drive the inline role-edit affordance
|
|
// without a second round-trip. JSON-merge via a small wrapper that
|
|
// embeds the existing Project shape — every existing caller keeps
|
|
// reading the same fields and gains effective_admin as additive.
|
|
effAdmin, err := dbSvc.team.IsEffectiveProjectAdmin(r.Context(), uid, id)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
type projectWithPermissions struct {
|
|
*models.Project
|
|
EffectiveAdmin bool `json:"effective_admin"`
|
|
}
|
|
writeJSON(w, http.StatusOK, projectWithPermissions{
|
|
Project: p,
|
|
EffectiveAdmin: effAdmin,
|
|
})
|
|
}
|
|
|
|
// GET /api/projects/{id}/children — direct children.
|
|
func handleListProjectChildren(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
rows, err := dbSvc.projects.ListChildren(r.Context(), uid, id)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// GET /api/projects/tree — nested tree of every visible Project. Each node
|
|
// carries open/overdue deadline counts and embedded children so the UI can
|
|
// render the full hierarchy in one round-trip. Visibility-scoped.
|
|
//
|
|
// Query parameters (all optional, additive):
|
|
// ?scope=all|mine|pinned — chip-driven scope (default "all")
|
|
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
|
|
// ?type=client,litigation,patent,case,project,other — type whitelist
|
|
// ?has_open_deadlines=true|false — narrow by deadline activity
|
|
// ?q=<term> — search title / reference / clientmatter
|
|
// ?subtree_counts=true|false — populate *_subtree fields (default true)
|
|
//
|
|
// Zero query string preserves the legacy behaviour for back-compat (existing
|
|
// callers that just want every visible project).
|
|
func handleGetProjectsTree(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
q := r.URL.Query()
|
|
opts := services.BuildTreeOptions{
|
|
IncludeSubtreeCounts: parseBoolQuery(q.Get("subtree_counts"), true),
|
|
SearchTerm: q.Get("q"),
|
|
StatusIn: splitCSV(q.Get("status")),
|
|
TypeIn: splitCSV(q.Get("type")),
|
|
}
|
|
switch q.Get("scope") {
|
|
case "mine":
|
|
opts.Scope = services.ScopeMine
|
|
case "pinned":
|
|
opts.Scope = services.ScopePinned
|
|
}
|
|
if v := q.Get("has_open_deadlines"); v != "" {
|
|
b := parseBoolQuery(v, false)
|
|
opts.HasOpenDeadlines = &b
|
|
}
|
|
|
|
// Pin set is needed when the response carries `pinned: bool` per node
|
|
// (always, when PinService is wired) AND when scope=pinned narrows.
|
|
if dbSvc.pin != nil {
|
|
set, err := dbSvc.pin.PinnedSet(r.Context(), uid)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
opts.PinnedSet = set
|
|
} else if opts.Scope == services.ScopePinned {
|
|
// scope=pinned without PinService can never have hits.
|
|
writeJSON(w, http.StatusOK, []any{})
|
|
return
|
|
}
|
|
|
|
tree, err := dbSvc.projects.BuildTreeWithOptions(r.Context(), uid, opts)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tree)
|
|
}
|
|
|
|
// parseBoolQuery accepts true/false/1/0/yes/no/on/off (case-insensitive).
|
|
// Falls back to def for empty / unrecognised input.
|
|
func parseBoolQuery(v string, def bool) bool {
|
|
switch v {
|
|
case "true", "1", "yes", "on":
|
|
return true
|
|
case "false", "0", "no", "off":
|
|
return false
|
|
default:
|
|
return def
|
|
}
|
|
}
|
|
|
|
// GET /api/projects/cards-preview — per-project event rollups for the
|
|
// Cards view. Returns a flat list of {project_id, next_events,
|
|
// recent_verlauf, team_initials, team_count, last_activity_at} for every
|
|
// project the user can see (or the subset given via ?ids=<csv-of-uuids>).
|
|
//
|
|
// Visibility-scoped server-side. Caller (Cards mode) lazy-fetches batches
|
|
// via IntersectionObserver.
|
|
func handleProjectsCardsPreview(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var ids []uuid.UUID
|
|
if raw := r.URL.Query().Get("ids"); raw != "" {
|
|
for _, s := range splitCSV(raw) {
|
|
u, err := uuid.Parse(s)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid uuid in ?ids"})
|
|
return
|
|
}
|
|
ids = append(ids, u)
|
|
}
|
|
}
|
|
|
|
previews, err := dbSvc.projects.CardsPreview(r.Context(), uid, ids)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
// Flat array for JSON; the map order is irrelevant to the client (it
|
|
// keys on project_id when stitching to its tree-id list).
|
|
out := make([]*services.ProjectCardPreview, 0, len(previews))
|
|
for _, p := range previews {
|
|
out = append(out, p)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// splitCSV splits a comma-separated query value into trimmed non-empty
|
|
// tokens. Empty input → nil so callers can branch on `len(out) > 0`.
|
|
func splitCSV(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(s, ",")
|
|
out := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
if p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
if len(out) == 0 {
|
|
return nil
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GET /api/projects/{id}/tree — full subtree depth-first (path-ordered).
|
|
func handleGetProjectTree(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
rows, err := dbSvc.projects.GetTree(r.Context(), uid, id)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// GET /api/projects/{id}/ancestors — ancestor chain for breadcrumbs.
|
|
func handleListProjectAncestors(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
rows, err := dbSvc.projects.ListAncestors(r.Context(), uid, id)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// PATCH /api/projects/{id}
|
|
func handleUpdateProject(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
var input services.UpdateProjectInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
p, err := dbSvc.projects.Update(r.Context(), uid, id, input)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, p)
|
|
}
|
|
|
|
// DELETE /api/projects/{id}
|
|
func handleDeleteProject(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
if err := dbSvc.projects.Delete(r.Context(), uid, id); err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// GET /api/projects/{id}/events — audit trail with cursor pagination.
|
|
func handleListProjectEvents(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
var before *uuid.UUID
|
|
if b := q.Get("before"); b != "" {
|
|
bu, err := uuid.Parse(b)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid before cursor"})
|
|
return
|
|
}
|
|
before = &bu
|
|
}
|
|
limit := 0
|
|
if l := q.Get("limit"); l != "" {
|
|
n, err := strconv.Atoi(l)
|
|
if err != nil || n < 0 {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid limit"})
|
|
return
|
|
}
|
|
limit = n
|
|
}
|
|
directOnly := parseDirectOnly(q.Get("direct_only"))
|
|
rows, err := dbSvc.projects.ListEvents(r.Context(), uid, id, before, limit, directOnly)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// GET /api/projects/{id}/parties
|
|
func handleListParties(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
rows, err := dbSvc.parties.ListForProject(r.Context(), uid, id)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, rows)
|
|
}
|
|
|
|
// POST /api/projects/{id}/parties
|
|
func handleCreateParty(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
id, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
var input services.CreatePartyInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
|
return
|
|
}
|
|
p, err := dbSvc.parties.Create(r.Context(), uid, id, input)
|
|
if err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, p)
|
|
}
|
|
|
|
// DELETE /api/parties/{id}
|
|
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
|
|
if !requireDB(w) {
|
|
return
|
|
}
|
|
uid, ok := requireUser(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
partyID, err := uuid.Parse(r.PathValue("id"))
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
|
return
|
|
}
|
|
if err := dbSvc.parties.Delete(r.Context(), uid, partyID); err != nil {
|
|
writeServiceError(w, err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|