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'
275 lines
9.3 KiB
Go
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"
|
|
}
|
|
}
|
|
}
|