Files
paliad/internal/services/agenda_service.go
m b34500ad31 feat(t-paliad-051): split paliad.users.role into job_title + global_role
Conflation: paliad.users.role was simultaneously job title (display only)
and global permission ('role=admin' checks across Go/SQL/JS). m wanted
to set his real job title ('Counsel Knowledge Lawyer') without losing
admin access — the t-paliad-050 admin-team UI even rejected role='admin'
on edit, so any UI-driven update silently demoted m.

Per m's three-axis principle ("firm roles are not project roles are not
tool roles"), this lands TWO orthogonal columns:

* paliad.users.job_title — free text, NULL allowed, display only.
  NEVER gates anything in code or SQL.
* paliad.users.global_role — CHECK ('standard'|'global_admin'),
  default 'standard'. The only thing that gates ops.

Migration 023:
* Drops NOT NULL + 'associate' default off the legacy role column
* Promotes role='admin' rows to global_role='global_admin'; clears
  their role text; sets m's job_title='Counsel Knowledge Lawyer'
* Renames role -> job_title with CHECK (job_title IS NULL OR <> '')
* Replaces can_see_project body with global_role='global_admin'
* CASCADE-rebuilds every RLS policy under canonical English names —
  with the historic u.role IN ('partner','admin') gates simplified
  to u.global_role='global_admin' only (job_title NEVER gates)

Code surface:
* internal/models/models.go: User.Role -> User.JobTitle (*string) +
  User.GlobalRole (string)
* internal/services/user_service.go: bootstrap (first row promoted to
  global_admin via pg_advisory_xact_lock(7346298141), unchanged constant);
  UpdateProfile drops role, accepts job_title only; AdminUpdateUser adds
  global_role with last-admin demotion guard (ErrLastGlobalAdmin);
  IsAdmin reads global_role
* Other services (dashboard/agenda/appointment/project/deadline/
  department/party/note/checklist_instance): pass user.GlobalRole into
  visibility predicates; partner-or-admin gates simplified to
  global_admin only
* Handlers: drop now-impossible ErrAdminBootstrapOnly cases;
  admin_users handles ErrLastGlobalAdmin -> 409
* department_service: SQL u.role -> u.job_title, DepartmentMember.Role
  -> JobTitle (*string)

Frontend:
* /api/me + Me interfaces ship {job_title, global_role}
* Onboarding form: 'Berufsbezeichnung / Job title' (job_title)
* Settings + admin-team forms: same renames + i18n updates
* Admin-team: new 'Berechtigung / Permission' column with
  'Standard'|'Global Admin' badge + dropdown editor; last-admin
  demotion guard at the UI layer
* Sidebar admin-section reveal: me.global_role==='global_admin'
* deadlines/deadlines-detail/projects-detail/notes: partner-as-permission
  gates dropped, only global_admin grants those operations

Tests:
* user_service_test: bootstrap promotes first user to global_admin,
  subsequent default to standard; AdminUpdateUser refuses to demote
  the last global_admin; IsAdmin reads global_role

Migration applied to ydb 2026-04-27. Live state verified:
* m: job_title='Counsel Knowledge Lawyer', global_role='global_admin'
* tester: job_title=NULL, global_role='global_admin'
* 29 stub colleagues: job_title='associate', global_role='standard'
2026-04-27 14:59:03 +02:00

275 lines
9.3 KiB
Go

package services
// AgendaService builds a merged, date-sorted feed of deadlines + appointments
// across every Project the caller can see. It underpins the `/agenda` page —
// a unified timeline that is neither deadline-centric (like /deadlines) nor
// appointment-centric (like /appointments/calendar).
//
// Visibility: reuses the same team-membership predicate applied everywhere
// else (paliad.project_teams + path walk). Personal Appointments (project_id
// IS NULL) remain creator-only.
import (
"context"
"fmt"
"sort"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// AgendaService returns agenda feed rows for the Dashboard's /agenda page.
type AgendaService struct {
db *sqlx.DB
users *UserService
}
// NewAgendaService wires the service.
func NewAgendaService(db *sqlx.DB, users *UserService) *AgendaService {
return &AgendaService{db: db, users: users}
}
// AgendaItem is one row in the merged feed. `Type` is "deadline" or
// "appointment"; date fields are populated differently per type (deadlines
// have a date-only DueDate, appointments have StartAt/EndAt). The client
// groups by the local calendar day derived from `Date`.
type AgendaItem struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"` // "deadline" | "appointment"
Title string `json:"title"`
Date time.Time `json:"date"` // canonical sort key (day start for deadlines, start_at for appointments)
EndAt *time.Time `json:"end_at,omitempty"` // appointments only
DueDate *string `json:"due_date,omitempty"` // deadlines only (YYYY-MM-DD)
Status *string `json:"status,omitempty"` // deadlines: pending/completed/...
Location *string `json:"location,omitempty"` // appointments only
AppointmentType *string `json:"appointment_type,omitempty"`
Urgency string `json:"urgency"` // overdue | today | tomorrow | this_week | later
ProjectID *uuid.UUID `json:"project_id,omitempty"`
ProjectTitle *string `json:"project_title,omitempty"`
ProjectType *string `json:"project_type,omitempty"`
ProjectRef *string `json:"project_reference,omitempty"`
}
// AgendaFilter narrows the merged feed.
type AgendaFilter struct {
From time.Time // inclusive, UTC
To time.Time // exclusive, UTC
IncludeDeadlines bool
IncludeAppointments bool
}
// List returns all AgendaItems for the user's visible projects within
// [From, To), sorted by Date ascending. Completed deadlines are excluded —
// the agenda is about what's coming up, not audit history.
func (s *AgendaService) List(ctx context.Context, userID uuid.UUID, f AgendaFilter) ([]AgendaItem, error) {
if !f.IncludeDeadlines && !f.IncludeAppointments {
return []AgendaItem{}, nil
}
if f.To.Before(f.From) || f.To.Equal(f.From) {
return []AgendaItem{}, nil
}
user, err := s.users.GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []AgendaItem{}, nil
}
items := make([]AgendaItem, 0, 64)
if f.IncludeDeadlines {
rows, err := s.loadDeadlines(ctx, userID, user.GlobalRole, f.From, f.To)
if err != nil {
return nil, err
}
items = append(items, rows...)
}
if f.IncludeAppointments {
rows, err := s.loadAppointments(ctx, userID, user.GlobalRole, f.From, f.To)
if err != nil {
return nil, err
}
items = append(items, rows...)
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].Date.Equal(items[j].Date) {
// Stable tiebreaker: deadlines before appointments on the same
// instant, then alphabetic by title so the feed is deterministic.
if items[i].Type != items[j].Type {
return items[i].Type == "deadline"
}
return items[i].Title < items[j].Title
}
return items[i].Date.Before(items[j].Date)
})
annotateAgendaUrgency(items, time.Now().UTC())
return items, nil
}
// loadDeadlines pulls pending deadlines whose due_date falls in [from, to).
// Completed deadlines are hidden — agenda is forward-looking.
func (s *AgendaService) loadDeadlines(ctx context.Context, userID uuid.UUID, role string, from, to time.Time) ([]AgendaItem, error) {
// due_date is a DATE; compare against the date portion of the window.
fromDate := from.Format("2006-01-02")
toDate := to.Format("2006-01-02")
query := `
SELECT f.id,
f.title,
f.due_date,
f.status,
p.id AS project_id,
p.title AS project_title,
p.type AS project_type,
p.reference AS project_reference
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
WHERE f.status = 'pending'
AND f.due_date >= $3::date
AND f.due_date < $4::date
AND ($2 = 'global_admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
))
ORDER BY f.due_date ASC, f.created_at ASC`
type row struct {
ID uuid.UUID `db:"id"`
Title string `db:"title"`
DueDate time.Time `db:"due_date"`
Status string `db:"status"`
ProjectID uuid.UUID `db:"project_id"`
ProjectTitle string `db:"project_title"`
ProjectType string `db:"project_type"`
ProjectReference *string `db:"project_reference"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, userID, role, fromDate, toDate); err != nil {
return nil, fmt.Errorf("agenda deadlines: %w", err)
}
out := make([]AgendaItem, 0, len(rows))
for _, r := range rows {
due := r.DueDate.Format("2006-01-02")
status := r.Status
projectID := r.ProjectID
projectTitle := r.ProjectTitle
projectType := r.ProjectType
out = append(out, AgendaItem{
ID: r.ID,
Type: "deadline",
Title: r.Title,
Date: time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC),
DueDate: &due,
Status: &status,
ProjectID: &projectID,
ProjectTitle: &projectTitle,
ProjectType: &projectType,
ProjectRef: r.ProjectReference,
})
}
return out, nil
}
// loadAppointments pulls appointments whose start_at falls in [from, to).
// Includes personal appointments (project_id IS NULL, creator-only) and
// project-attached appointments subject to the team predicate.
func (s *AgendaService) loadAppointments(ctx context.Context, userID uuid.UUID, role string, from, to time.Time) ([]AgendaItem, error) {
query := `
SELECT t.id,
t.title,
t.start_at,
t.end_at,
t.location,
t.appointment_type,
t.project_id,
p.title AS project_title,
p.type AS project_type,
p.reference AS project_reference
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE t.start_at >= $3
AND t.start_at < $4
AND (
(t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND ($2 = 'global_admin' OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
)))
)
ORDER BY t.start_at ASC, t.created_at ASC`
type row struct {
ID uuid.UUID `db:"id"`
Title string `db:"title"`
StartAt time.Time `db:"start_at"`
EndAt *time.Time `db:"end_at"`
Location *string `db:"location"`
AppointmentType *string `db:"appointment_type"`
ProjectID *uuid.UUID `db:"project_id"`
ProjectTitle *string `db:"project_title"`
ProjectType *string `db:"project_type"`
ProjectReference *string `db:"project_reference"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, userID, role, from, to); err != nil {
return nil, fmt.Errorf("agenda appointments: %w", err)
}
out := make([]AgendaItem, 0, len(rows))
for _, r := range rows {
out = append(out, AgendaItem{
ID: r.ID,
Type: "appointment",
Title: r.Title,
Date: r.StartAt,
EndAt: r.EndAt,
Location: r.Location,
AppointmentType: r.AppointmentType,
ProjectID: r.ProjectID,
ProjectTitle: r.ProjectTitle,
ProjectType: r.ProjectType,
ProjectRef: r.ProjectReference,
})
}
return out, nil
}
// annotateAgendaUrgency classifies each item so the client can apply the
// traffic-light styling without re-deriving the buckets.
//
// overdue — in the past (deadlines only; appointments only go "later")
// today — same calendar day (UTC — kept in sync with server window)
// tomorrow — next calendar day
// this_week — within the next 7 days (exclusive of today/tomorrow)
// later — beyond 7 days
func annotateAgendaUrgency(items []AgendaItem, now time.Time) {
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
tomorrow := today.AddDate(0, 0, 1)
dayAfterTomorrow := today.AddDate(0, 0, 2)
endOfWeek := today.AddDate(0, 0, 7)
for i := range items {
d := items[i].Date
switch {
case d.Before(today):
items[i].Urgency = "overdue"
case !d.Before(today) && d.Before(tomorrow):
items[i].Urgency = "today"
case !d.Before(tomorrow) && d.Before(dayAfterTomorrow):
items[i].Urgency = "tomorrow"
case !d.Before(dayAfterTomorrow) && d.Before(endOfWeek):
items[i].Urgency = "this_week"
default:
items[i].Urgency = "later"
}
}
}