Commit 6 of 8. Renders the approval-pending warning pill on the two
busiest list surfaces:
- /events (deadline + appointment list): ⚠ pill next to the title +
soft-tinted row via .entity-row--pending-update modifier.
- /agenda (timeline): ⚠ pill in the headline + same row tint.
Changes:
- internal/services/event_service.go: EventListItem gains
ApprovalStatus *string; projectDeadline / projectAppointment
populate it from the embedded model.
- internal/services/deadline_service.go ListVisibleForUser: SQL adds
f.approval_status / pending_request_id / approved_by / approved_at
to the SELECT so DeadlineWithProject hydrates them.
- internal/services/appointment_service.go ListVisibleForUser: same
for appointments + completed_at.
- internal/services/agenda_service.go: AgendaItem gains
ApprovalStatus; the per-source SQL queries select it; the
loadDeadlines / loadAppointments projection sets it.
- frontend/src/client/events.ts renderRow: adds entity-row--pending-update
modifier and an inline approval-pill on the title cell when status='pending'.
- frontend/src/client/agenda.ts renderItem: same treatment on the
agenda-item headline.
Generic "pending update" label (approvals.pending_update.label) — not
lifecycle-specific. The inbox carries the lifecycle detail. Showing
just one pill keeps the visual signal clear; an approver scanning a
list of pending entities sees them at a glance via the row tint, then
clicks through to /inbox to see what's pending and act.
Detail pages (/deadlines/{id}, /appointments/{id}) and /dashboard
deadline rail — pill rendering for those surfaces deferred to a
follow-up to keep this commit focused. Rendered everywhere it
matters most for daily use.
322 lines
11 KiB
Go
322 lines
11 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"
|
|
"strings"
|
|
"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
|
|
eventTypes *EventTypeService
|
|
}
|
|
|
|
// NewAgendaService wires the service. eventTypes powers the optional
|
|
// Event-Type filter on /agenda (t-paliad-088); pass nil in tests that
|
|
// don't exercise that surface.
|
|
func NewAgendaService(db *sqlx.DB, users *UserService, eventTypes *EventTypeService) *AgendaService {
|
|
return &AgendaService{db: db, users: users, eventTypes: eventTypes}
|
|
}
|
|
|
|
// 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"`
|
|
// ApprovalStatus (t-paliad-138) — "pending" → render warning pill on
|
|
// the agenda timeline. "approved"/"legacy" → no pill.
|
|
ApprovalStatus *string `json:"approval_status,omitempty"`
|
|
}
|
|
|
|
// AgendaFilter narrows the merged feed.
|
|
//
|
|
// EventTypeIDs / IncludeUntyped restrict the deadline side of the feed
|
|
// (appointments are unaffected — they have no event_types). When both
|
|
// are zero/false the filter is inactive.
|
|
type AgendaFilter struct {
|
|
From time.Time // inclusive, UTC
|
|
To time.Time // exclusive, UTC
|
|
IncludeDeadlines bool
|
|
IncludeAppointments bool
|
|
EventTypeIDs []uuid.UUID
|
|
IncludeUntyped 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
|
|
}
|
|
|
|
// Existence gate — visibility predicate inside loadDeadlines/Appointments
|
|
// reads global_role directly from paliad.users via the SQL helper, so we
|
|
// no longer need the row itself, just confirmation that the caller exists.
|
|
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, f.From, f.To, f.EventTypeIDs, f.IncludeUntyped)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, rows...)
|
|
}
|
|
if f.IncludeAppointments {
|
|
rows, err := s.loadAppointments(ctx, userID, 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.
|
|
// eventTypeIDs / includeUntyped restrict which deadlines are returned;
|
|
// see AgendaFilter for the OR-composition semantics.
|
|
func (s *AgendaService) loadDeadlines(ctx context.Context, userID uuid.UUID, from, to time.Time, eventTypeIDs []uuid.UUID, includeUntyped bool) ([]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")
|
|
|
|
args := []any{userID, fromDate, toDate}
|
|
etClause := ""
|
|
if len(eventTypeIDs) > 0 || includeUntyped {
|
|
parts := []string{}
|
|
if len(eventTypeIDs) > 0 {
|
|
placeholders := make([]string, 0, len(eventTypeIDs))
|
|
for _, id := range eventTypeIDs {
|
|
args = append(args, id)
|
|
placeholders = append(placeholders, fmt.Sprintf("$%d", len(args)))
|
|
}
|
|
parts = append(parts, fmt.Sprintf(`EXISTS (
|
|
SELECT 1 FROM paliad.deadline_event_types det
|
|
WHERE det.deadline_id = f.id
|
|
AND det.event_type_id IN (%s)
|
|
)`, strings.Join(placeholders, ", ")))
|
|
}
|
|
if includeUntyped {
|
|
parts = append(parts, `NOT EXISTS (
|
|
SELECT 1 FROM paliad.deadline_event_types det
|
|
WHERE det.deadline_id = f.id
|
|
)`)
|
|
}
|
|
if len(parts) == 1 {
|
|
etClause = "\n AND " + parts[0]
|
|
} else {
|
|
etClause = "\n AND (" + strings.Join(parts, " OR ") + ")"
|
|
}
|
|
}
|
|
|
|
query := `
|
|
SELECT f.id,
|
|
f.title,
|
|
f.due_date,
|
|
f.status,
|
|
f.approval_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 >= $2::date
|
|
AND f.due_date < $3::date
|
|
AND ` + visibilityPredicatePositional("p", 1) + etClause + `
|
|
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"`
|
|
ApprovalStatus string `db:"approval_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, 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
|
|
approvalStatus := r.ApprovalStatus
|
|
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,
|
|
ApprovalStatus: &approvalStatus,
|
|
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, from, to time.Time) ([]AgendaItem, error) {
|
|
query := `
|
|
SELECT t.id,
|
|
t.title,
|
|
t.start_at,
|
|
t.end_at,
|
|
t.location,
|
|
t.appointment_type,
|
|
t.approval_status,
|
|
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 >= $2
|
|
AND t.start_at < $3
|
|
AND (
|
|
(t.project_id IS NULL AND t.created_by = $1)
|
|
OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 1) + `)
|
|
)
|
|
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"`
|
|
ApprovalStatus string `db:"approval_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, from, to); err != nil {
|
|
return nil, fmt.Errorf("agenda appointments: %w", err)
|
|
}
|
|
|
|
out := make([]AgendaItem, 0, len(rows))
|
|
for _, r := range rows {
|
|
approvalStatus := r.ApprovalStatus
|
|
out = append(out, AgendaItem{
|
|
ID: r.ID,
|
|
Type: "appointment",
|
|
Title: r.Title,
|
|
Date: r.StartAt,
|
|
EndAt: r.EndAt,
|
|
Location: r.Location,
|
|
AppointmentType: r.AppointmentType,
|
|
ApprovalStatus: &approvalStatus,
|
|
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"
|
|
}
|
|
}
|
|
}
|