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'
308 lines
11 KiB
Go
308 lines
11 KiB
Go
package services
|
|
|
|
// DashboardService aggregates the logged-in landing-page payload. Scoped to
|
|
// Projects the caller can see — same predicate as ProjectService (team-based,
|
|
// v2 data model, t-paliad-024).
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/patholo/internal/models"
|
|
)
|
|
|
|
// DashboardService reads paliad.projects/deadlines/appointments/project_events for
|
|
// the Dashboard page.
|
|
type DashboardService struct {
|
|
db *sqlx.DB
|
|
users *UserService
|
|
}
|
|
|
|
func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService {
|
|
return &DashboardService{db: db, users: users}
|
|
}
|
|
|
|
// DashboardData is the full payload returned to the frontend.
|
|
type DashboardData struct {
|
|
User *DashboardUser `json:"user"`
|
|
DeadlineSummary DeadlineSummary `json:"deadline_summary"`
|
|
MatterSummary MatterSummary `json:"matter_summary"`
|
|
UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"`
|
|
UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"`
|
|
RecentActivity []ActivityEntry `json:"recent_activity"`
|
|
}
|
|
|
|
type DashboardUser struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Email string `json:"email"`
|
|
DisplayName string `json:"display_name"`
|
|
Office string `json:"office"`
|
|
JobTitle *string `json:"job_title"`
|
|
GlobalRole string `json:"global_role"`
|
|
}
|
|
|
|
type DeadlineSummary struct {
|
|
Overdue int `json:"overdue" db:"overdue"`
|
|
ThisWeek int `json:"this_week" db:"this_week"`
|
|
Upcoming int `json:"upcoming" db:"upcoming"`
|
|
CompletedThisWeek int `json:"completed_this_week" db:"completed_this_week"`
|
|
}
|
|
|
|
// MatterSummary counts visible Projects by status. Field names kept as
|
|
// "matter" for JSON API compatibility with the dashboard client.
|
|
type MatterSummary struct {
|
|
Active int `json:"active" db:"active"`
|
|
Archived int `json:"archived" db:"archived"`
|
|
Total int `json:"total" db:"total"`
|
|
}
|
|
|
|
// UpcomingDeadline is one row for "Kommende Deadlines".
|
|
type UpcomingDeadline struct {
|
|
ID uuid.UUID `json:"id" db:"id"`
|
|
Title string `json:"title" db:"title"`
|
|
DueDate string `json:"due_date" db:"due_date"`
|
|
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
|
|
ProjectTitle string `json:"project_title" db:"project_title"`
|
|
ProjectRef string `json:"project_reference" db:"project_reference"`
|
|
Urgency string `json:"urgency"`
|
|
}
|
|
|
|
// UpcomingAppointment is one row for "Kommende Appointments".
|
|
type UpcomingAppointment struct {
|
|
ID uuid.UUID `json:"id" db:"id"`
|
|
Title string `json:"title" db:"title"`
|
|
StartAt time.Time `json:"start_at" db:"start_at"`
|
|
EndAt *time.Time `json:"end_at" db:"end_at"`
|
|
Type *string `json:"type" db:"appointment_type"`
|
|
ProjectID *uuid.UUID `json:"project_id" db:"project_id"`
|
|
ProjectTitle *string `json:"project_title" db:"project_title"`
|
|
ProjectRef *string `json:"project_reference" db:"project_reference"`
|
|
}
|
|
|
|
// ActivityEntry is one row in the "Letzte Aktivität" feed.
|
|
type ActivityEntry struct {
|
|
Timestamp time.Time `json:"timestamp" db:"timestamp"`
|
|
ActorEmail *string `json:"actor_email" db:"actor_email"`
|
|
ActorName *string `json:"actor_name" db:"actor_name"`
|
|
ProjectID uuid.UUID `json:"project_id" db:"project_id"`
|
|
ProjectTitle string `json:"project_title" db:"project_title"`
|
|
ProjectRef string `json:"project_reference" db:"project_reference"`
|
|
Action *string `json:"action" db:"action"`
|
|
Details string `json:"details" db:"details"`
|
|
Description *string `json:"description" db:"description"`
|
|
}
|
|
|
|
// Get builds the full dashboard payload.
|
|
func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*DashboardData, error) {
|
|
user, err := s.users.GetByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data := &DashboardData{
|
|
UpcomingDeadlines: []UpcomingDeadline{},
|
|
UpcomingAppointments: []UpcomingAppointment{},
|
|
RecentActivity: []ActivityEntry{},
|
|
}
|
|
if user == nil {
|
|
return data, nil
|
|
}
|
|
data.User = &DashboardUser{
|
|
ID: user.ID,
|
|
Email: user.Email,
|
|
DisplayName: user.DisplayName,
|
|
Office: user.Office,
|
|
JobTitle: user.JobTitle,
|
|
GlobalRole: user.GlobalRole,
|
|
}
|
|
|
|
now := time.Now()
|
|
today := now.Format("2006-01-02")
|
|
endOfWindow := now.AddDate(0, 0, 7).Format("2006-01-02")
|
|
sevenDaysAgo := now.AddDate(0, 0, -7).UTC()
|
|
|
|
if err := s.loadSummary(ctx, data, user, today, endOfWindow, sevenDaysAgo); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.loadUpcomingDeadlines(ctx, data, user, today, endOfWindow); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.loadUpcomingAppointments(ctx, data, user, now); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.loadRecentActivity(ctx, data, user); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
annotateUrgency(data.UpcomingDeadlines, now)
|
|
return data, nil
|
|
}
|
|
|
|
// loadSummary fills DeadlineSummary + MatterSummary.
|
|
//
|
|
// Visibility predicate: admin OR any ancestor-or-direct team membership.
|
|
// Applied once via a CTE; downstream queries reuse the same pattern.
|
|
func (s *DashboardService) loadSummary(ctx context.Context, data *DashboardData, user *models.User, today, endOfWeek string, sevenDaysAgo time.Time) error {
|
|
query := `
|
|
WITH visible_projekte AS (
|
|
SELECT p.id, p.status
|
|
FROM paliad.projects p
|
|
WHERE $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[])
|
|
)
|
|
),
|
|
deadline_stats AS (
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE f.due_date < $3::date AND f.status = 'pending') AS overdue,
|
|
COUNT(*) FILTER (WHERE f.due_date >= $3::date AND f.due_date <= $4::date AND f.status = 'pending') AS this_week,
|
|
COUNT(*) FILTER (WHERE f.due_date > $4::date AND f.status = 'pending') AS upcoming,
|
|
COUNT(*) FILTER (WHERE f.status = 'completed' AND f.completed_at >= $5) AS completed_this_week
|
|
FROM paliad.deadlines f
|
|
JOIN visible_projekte v ON v.id = f.project_id
|
|
),
|
|
matter_stats AS (
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'active') AS active,
|
|
COUNT(*) FILTER (WHERE status = 'archived') AS archived,
|
|
COUNT(*) AS total
|
|
FROM visible_projekte
|
|
)
|
|
SELECT ds.overdue, ds.this_week, ds.upcoming, ds.completed_this_week,
|
|
ms.active, ms.archived, ms.total
|
|
FROM deadline_stats ds, matter_stats ms`
|
|
|
|
var row struct {
|
|
DeadlineSummary
|
|
MatterSummary
|
|
}
|
|
err := s.db.GetContext(ctx, &row, query,
|
|
user.ID, user.GlobalRole, today, endOfWeek, sevenDaysAgo)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("dashboard summary: %w", err)
|
|
}
|
|
data.DeadlineSummary = row.DeadlineSummary
|
|
data.MatterSummary = row.MatterSummary
|
|
return nil
|
|
}
|
|
|
|
func (s *DashboardService) loadUpcomingDeadlines(ctx context.Context, data *DashboardData, user *models.User, today, endOfWeek string) error {
|
|
query := `
|
|
SELECT f.id,
|
|
f.title,
|
|
to_char(f.due_date, 'YYYY-MM-DD') AS due_date,
|
|
p.id AS project_id,
|
|
p.title AS project_title,
|
|
COALESCE(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
|
|
LIMIT 10`
|
|
if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query,
|
|
user.ID, user.GlobalRole, today, endOfWeek); err != nil {
|
|
return fmt.Errorf("dashboard upcoming deadlines: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error {
|
|
query := `
|
|
SELECT t.id,
|
|
t.title,
|
|
t.start_at,
|
|
t.end_at,
|
|
t.appointment_type,
|
|
t.project_id,
|
|
p.title AS project_title,
|
|
COALESCE(p.reference, NULL) 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 < ($3 + interval '7 days')
|
|
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
|
|
LIMIT 10`
|
|
if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query,
|
|
user.ID, user.GlobalRole, now); err != nil {
|
|
return fmt.Errorf("dashboard upcoming appointments: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *DashboardService) loadRecentActivity(ctx context.Context, data *DashboardData, user *models.User) error {
|
|
query := `
|
|
SELECT COALESCE(e.event_date, e.created_at) AS timestamp,
|
|
u.email AS actor_email,
|
|
u.display_name AS actor_name,
|
|
e.project_id,
|
|
p.title AS project_title,
|
|
COALESCE(p.reference, '') AS project_reference,
|
|
e.event_type AS action,
|
|
e.title AS details,
|
|
e.description
|
|
FROM paliad.project_events e
|
|
JOIN paliad.projects p ON p.id = e.project_id
|
|
LEFT JOIN paliad.users u ON u.id = e.created_by
|
|
WHERE $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 COALESCE(e.event_date, e.created_at) DESC
|
|
LIMIT 10`
|
|
if err := s.db.SelectContext(ctx, &data.RecentActivity, query,
|
|
user.ID, user.GlobalRole); err != nil {
|
|
return fmt.Errorf("dashboard recent activity: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func annotateUrgency(deadlines []UpcomingDeadline, now time.Time) {
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
for i := range deadlines {
|
|
due, err := time.ParseInLocation("2006-01-02", deadlines[i].DueDate, now.Location())
|
|
if err != nil {
|
|
deadlines[i].Urgency = "soon"
|
|
continue
|
|
}
|
|
days := int(due.Sub(today).Hours() / 24)
|
|
switch {
|
|
case days < 0:
|
|
deadlines[i].Urgency = "overdue"
|
|
case days == 0:
|
|
deadlines[i].Urgency = "today"
|
|
case days <= 2:
|
|
deadlines[i].Urgency = "urgent"
|
|
default:
|
|
deadlines[i].Urgency = "soon"
|
|
}
|
|
}
|
|
}
|