Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):
- paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
distinction so derivation can target specific tiers without re-
introducing a firm-wide rank column. The same human can be 'attorney'
in one unit and 'lead' in another.
- paliad.project_partner_units junction (project_id, partner_unit_id,
derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
DEFAULT false, attached_at, attached_by) with composite PK and RLS
(read = can_see_project; write = global_admin OR project lead).
- paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
derived authority is consulted by the t-138 ladder.
- paliad.can_see_project extended with one EXISTS branch — derivation
walks the path: a user is visible on P if any (ancestor of P) is
attached to a unit they are a member of with a matching unit_role.
No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.
Backend services
----------------
- DerivationService (new, internal/services/derivation_service.go):
AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
ListDerivedMembers (path-walking dedupe by closest attachment),
ListDescendantStaffed (descendant-direct rows excluding ancestor-
already-staffed), EffectiveProjectRole (returns role + source ∈
{direct, ancestor, derived} for the t-138 approval gate in Phase 3).
- PartnerUnitService extensions:
PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
role read in tx, audit emit 'member_role_changed'. ListMembers and
ListWithMembers SELECT projection now includes pum.unit_role.
Handlers
--------
- GET /api/projects/{id}/partner-units → ListAttachedUnits
- POST /api/projects/{id}/partner-units → AttachUnitToProject
- DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
- GET /api/projects/{id}/team/derived → ListDerivedMembers
- GET /api/projects/{id}/team/from-descendants → ListDescendantStaffed
- PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
- Services bundle gains Derivation; cmd/server/main.go wires it.
Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
- "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
- "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
Augen] badge per the m-locked honesty rule (§3.5).
- "Partner Units" — attached-unit list with attach/detach controls
(lead/admin only) and a form picker for derive_unit_roles +
derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).
Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.
i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).
CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.
Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
201 lines
8.0 KiB
Go
201 lines
8.0 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"syscall"
|
|
|
|
// Embed Go's IANA tz database into the binary so time.LoadLocation works
|
|
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
|
|
// zoneinfo — without this import, every reminder timezone lookup fails
|
|
// silently and the hourly reminder slot fires in UTC instead of the
|
|
// user's chosen tz (t-paliad-064 root cause). Adds ~450KB to the binary.
|
|
_ "time/tzdata"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/auth"
|
|
"mgit.msbls.de/m/paliad/internal/branding"
|
|
"mgit.msbls.de/m/paliad/internal/db"
|
|
"mgit.msbls.de/m/paliad/internal/handlers"
|
|
"mgit.msbls.de/m/paliad/internal/services"
|
|
)
|
|
|
|
func main() {
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8080"
|
|
}
|
|
|
|
// Surface the firm name in the boot log so a deployer can confirm
|
|
// FIRM_NAME took effect without curl-ing a rendered page.
|
|
log.Printf("branding: firm=%q (override with FIRM_NAME)", branding.Name)
|
|
|
|
supabaseURL := os.Getenv("SUPABASE_URL")
|
|
supabaseAnonKey := os.Getenv("SUPABASE_ANON_KEY")
|
|
if supabaseURL == "" || supabaseAnonKey == "" {
|
|
log.Fatal("SUPABASE_URL and SUPABASE_ANON_KEY must be set")
|
|
}
|
|
|
|
jwtSecret := os.Getenv("SUPABASE_JWT_SECRET")
|
|
if jwtSecret == "" {
|
|
log.Fatal("SUPABASE_JWT_SECRET must be set — session cookies cannot be trusted without signature verification")
|
|
}
|
|
|
|
client := auth.NewClient(supabaseURL, supabaseAnonKey, []byte(jwtSecret))
|
|
|
|
giteaToken := os.Getenv("GITEA_TOKEN")
|
|
if giteaToken == "" {
|
|
log.Println("GITEA_TOKEN not set — file proxy will not be able to access private repos")
|
|
}
|
|
|
|
// MailService is wired regardless of DB availability — it no-ops when
|
|
// SMTP env vars are unset, so the server stays runnable for knowledge-
|
|
// platform-only deployments. Template-parse errors at boot are fatal.
|
|
mailSvc, err := services.NewMailService()
|
|
if err != nil {
|
|
log.Fatalf("mail service init: %v", err)
|
|
}
|
|
|
|
// Shared context for background goroutines (CalDAV sync + reminder job).
|
|
bgCtx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
|
defer stop()
|
|
|
|
// DATABASE_URL is optional during the Phase A → Phase D transition. The
|
|
// existing knowledge-platform features (Kostenrechner, Glossar, etc.) work
|
|
// without a DB. matter-management endpoints return 503 until DATABASE_URL is set.
|
|
dbURL := os.Getenv("DATABASE_URL")
|
|
var svcBundle *handlers.Services
|
|
var caldavSvc *services.CalDAVService
|
|
|
|
if dbURL != "" {
|
|
log.Println("applying database migrations…")
|
|
if err := db.ApplyMigrations(dbURL); err != nil {
|
|
log.Fatalf("migration failed: %v", err)
|
|
}
|
|
log.Println("database migrations applied")
|
|
|
|
pool, err := db.OpenPool(dbURL)
|
|
if err != nil {
|
|
log.Fatalf("open db pool: %v", err)
|
|
}
|
|
|
|
// Refresh paliad.deadline_search whenever migrations run, so
|
|
// search reflects any newly-seeded rule / concept / trigger.
|
|
// Migration 047 created the matview already-populated; this
|
|
// is only a no-op for the boot that introduced it. CONCURRENTLY
|
|
// keeps reads online and stays well under 100 ms at < 1k rows.
|
|
if err := services.RefreshSearchView(bgCtx, pool); err != nil {
|
|
log.Printf("refresh deadline_search: %v", err)
|
|
}
|
|
holidays := services.NewHolidayService(pool)
|
|
courts := services.NewCourtService(pool)
|
|
users := services.NewUserService(pool)
|
|
projectSvc := services.NewProjectService(pool, users)
|
|
teamSvc := services.NewTeamService(pool, projectSvc)
|
|
partnerUnitSvc := services.NewPartnerUnitService(pool, users)
|
|
rules := services.NewDeadlineRuleService(pool)
|
|
|
|
// Phase F: optional CalDAV cipher. If CALDAV_ENCRYPTION_KEY is unset
|
|
// the service exists but Enabled() reports false; handlers return 501.
|
|
// If the env var is malformed, fail fast — silently skipping would
|
|
// leave plaintext-credential bugs hidden.
|
|
cipher, err := services.LoadCalDAVCipher()
|
|
if err != nil {
|
|
log.Fatalf("CALDAV_ENCRYPTION_KEY: %v", err)
|
|
}
|
|
if cipher == nil {
|
|
log.Println("CALDAV_ENCRYPTION_KEY not set — CalDAV endpoints will return 501")
|
|
} else {
|
|
log.Println("CalDAV encryption configured (AES-256-GCM)")
|
|
}
|
|
|
|
appointmentSvc := services.NewAppointmentService(pool, projectSvc)
|
|
caldavSvc = services.NewCalDAVService(pool, cipher, appointmentSvc)
|
|
// Wire the push hook so user-driven mutations sync to the external
|
|
// calendar without waiting for the next 60-second tick.
|
|
appointmentSvc.SetCalDAVPusher(caldavSvc)
|
|
|
|
baseURL := os.Getenv("PALIAD_BASE_URL")
|
|
inviteSvc := services.NewInviteService(pool, mailSvc, handlers.AllowedEmailDomains, baseURL)
|
|
reminderSvc := services.NewReminderService(pool, mailSvc, users, baseURL)
|
|
|
|
// Wire EmailTemplateService onto the MailService so DB-backed admin
|
|
// edits propagate without a process restart. The constructor is split
|
|
// from MailService creation because the DB pool isn't available yet
|
|
// at the point we build mailSvc above.
|
|
emailTemplateSvc := services.NewEmailTemplateService(pool)
|
|
mailSvc.SetTemplateService(emailTemplateSvc)
|
|
|
|
eventTypeSvc := services.NewEventTypeService(pool, users)
|
|
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
|
svcBundle = &handlers.Services{
|
|
Project: projectSvc,
|
|
Team: teamSvc,
|
|
PartnerUnit: partnerUnitSvc,
|
|
Party: services.NewPartyService(pool, projectSvc),
|
|
Deadline: deadlineSvc,
|
|
Appointment: appointmentSvc,
|
|
CalDAV: caldavSvc,
|
|
Rules: rules,
|
|
Calculator: services.NewDeadlineCalculator(holidays),
|
|
Users: users,
|
|
Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
|
|
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays, courts),
|
|
Courts: courts,
|
|
DeadlineSearch: services.NewDeadlineSearchService(pool),
|
|
EventCategory: nil, // wired below; cross-link order matters
|
|
EventType: eventTypeSvc,
|
|
Dashboard: services.NewDashboardService(pool, users),
|
|
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
|
|
ChecklistInst: services.NewChecklistInstanceService(pool, projectSvc),
|
|
Mail: mailSvc,
|
|
Invite: inviteSvc,
|
|
Agenda: services.NewAgendaService(pool, users, eventTypeSvc),
|
|
Audit: services.NewAuditService(pool),
|
|
EmailTemplate: emailTemplateSvc,
|
|
Link: services.NewLinkService(pool),
|
|
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
|
|
Approval: services.NewApprovalService(pool, users),
|
|
Derivation: services.NewDerivationService(pool, projectSvc, partnerUnitSvc),
|
|
}
|
|
// Wire ApprovalService into the entity services so Create / Update /
|
|
// Complete / Delete consult paliad.approval_policies (t-paliad-138).
|
|
// Without this wiring, the policies and request tables exist but no
|
|
// mutation path consults them — paliad behaves as before.
|
|
deadlineSvc.SetApprovalService(svcBundle.Approval)
|
|
appointmentSvc.SetApprovalService(svcBundle.Approval)
|
|
// v3 (t-paliad-133): wire EventCategoryService and cross-link
|
|
// it into DeadlineSearchService so ?event_category_slug= can
|
|
// resolve to a concept-id allow-list during search.
|
|
eventCategorySvc := services.NewEventCategoryService(pool)
|
|
svcBundle.EventCategory = eventCategorySvc
|
|
svcBundle.DeadlineSearch.SetEventCategoryService(eventCategorySvc)
|
|
log.Println("Phase B services initialised")
|
|
|
|
// Spawn background goroutines: CalDAV sync (one per enabled user)
|
|
// and the hourly reminder scanner. Both live for the process
|
|
// lifetime; the signal-scoped context cleans them up on SIGTERM.
|
|
if err := caldavSvc.Start(bgCtx); err != nil {
|
|
log.Printf("CalDAV start: %v", err)
|
|
}
|
|
reminderSvc.Start(bgCtx)
|
|
go func() {
|
|
<-bgCtx.Done()
|
|
log.Println("background services: shutdown signal received")
|
|
caldavSvc.Stop()
|
|
}()
|
|
} else {
|
|
log.Println("DATABASE_URL not set — matter-management endpoints will return 503")
|
|
}
|
|
|
|
mux := http.NewServeMux()
|
|
handlers.Register(mux, client, giteaToken, svcBundle)
|
|
|
|
log.Printf("paliad server starting on :%s", port)
|
|
if err := http.ListenAndServe(":"+port, mux); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|