diff --git a/cmd/server/main.go b/cmd/server/main.go index 7f790a9..027f2b8 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -187,6 +187,14 @@ func main() { Export: services.NewExportService(pool, branding.Name), } + // t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService + // for the inbox-approvals widget. Done post-construction to avoid + // a circular constructor dependency (ApprovalService doesn't need + // the dashboard, and DashboardService can render its other widgets + // without approvals — so keeping this a setter keeps both + // constructors simple). + svcBundle.Dashboard.SetApprovalService(svcBundle.Approval) + // t-paliad-215 Slice 1 — submission generator. Three services // stitched together by handlers/submissions.go: registry pulls // templates from Gitea (reuses GITEA_TOKEN env), vars builds diff --git a/internal/services/dashboard_service.go b/internal/services/dashboard_service.go index 4f023dd..1e7673c 100644 --- a/internal/services/dashboard_service.go +++ b/internal/services/dashboard_service.go @@ -21,14 +21,24 @@ import ( // DashboardService reads paliad.projects/deadlines/appointments/project_events for // the Dashboard page. type DashboardService struct { - db *sqlx.DB - users *UserService + db *sqlx.DB + users *UserService + approvals *ApprovalService } func NewDashboardService(db *sqlx.DB, users *UserService) *DashboardService { return &DashboardService{db: db, users: users} } +// SetApprovalService wires the inbox-approvals widget data source. Called +// post-construction so that DashboardService and ApprovalService can be +// stitched together at boot without a circular constructor dependency. +// Safe to leave nil — InboxSummary will then carry pending_count=0 and an +// empty entries list, and the widget renders its empty state. +func (s *DashboardService) SetApprovalService(a *ApprovalService) { + s.approvals = a +} + // DashboardData is the full payload returned to the frontend. type DashboardData struct { User *DashboardUser `json:"user"` @@ -38,8 +48,42 @@ type DashboardData struct { UpcomingDeadlines []UpcomingDeadline `json:"upcoming_deadlines"` UpcomingAppointments []UpcomingAppointment `json:"upcoming_appointments"` RecentActivity []ActivityEntry `json:"recent_activity"` + InboxSummary InboxSummary `json:"inbox_summary"` } +// InboxSummary feeds the inbox-approvals widget on the configurable +// dashboard (t-paliad-219). PendingCount is the precise number of +// approval requests that await this user's approval; Top is a small +// preview list (up to InboxTopCap entries) ordered oldest-pending-first +// so the most urgent appears first. +// +// When the ApprovalService dependency is unwired (knowledge-platform-only +// deployments, tests), PendingCount=0 and Top=[] so the widget renders +// its empty state. The data path is read-only — no writes go through +// the dashboard payload. +type InboxSummary struct { + PendingCount int `json:"pending_count"` + Top []InboxEntry `json:"top"` +} + +// InboxEntry is a single row in InboxSummary.Top — the minimum needed +// to render a clickable preview ("Frist X auf Akte Y, vorgeschlagen am Z"). +type InboxEntry struct { + RequestID uuid.UUID `json:"id"` + EntityType string `json:"entity_type"` + EntityTitle *string `json:"entity_title,omitempty"` + ProjectID uuid.UUID `json:"project_id"` + ProjectTitle string `json:"project_title"` + RequestedAt time.Time `json:"requested_at"` + RequesterID uuid.UUID `json:"requester_id"` + RequesterName string `json:"requester_name"` +} + +// InboxTopCap caps the preview list. The widget's count setting tops out +// at 10 (see WidgetCatalog inboxCounts); we fetch the cap once and let +// the client trim further per the user's setting. +const InboxTopCap = 10 + type DashboardUser struct { ID uuid.UUID `json:"id"` Email string `json:"email"` @@ -146,7 +190,12 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar now := time.Now() today := now.Format("2006-01-02") - endOfWindow := now.AddDate(0, 0, 7).Format("2006-01-02") + // t-paliad-219 §18 Note B: widen the upcoming windows from 7d → 60d + // so the per-widget horizon dropdown (7/14/30/60) can filter client- + // side without re-querying. LIMIT bumps from 10 to 40 for the same + // reason — the widget's count setting tops out at 20 plus headroom + // for the agenda widget which can read from the same payload. + endOfWindow := now.AddDate(0, 0, 60).Format("2006-01-02") bounds := computeDeadlineBucketBounds(now.UTC()) if err := s.loadSummary(ctx, data, user, bounds); err != nil { @@ -161,6 +210,9 @@ func (s *DashboardService) Get(ctx context.Context, userID uuid.UUID) (*Dashboar if err := s.loadRecentActivity(ctx, data, user); err != nil { return nil, err } + if err := s.loadInboxSummary(ctx, data, user); err != nil { + return nil, err + } annotateUrgency(data.UpcomingDeadlines, now) return data, nil @@ -261,7 +313,7 @@ SELECT f.id, AND f.due_date <= $3::date AND ` + visibilityPredicatePositional("p", 1) + ` ORDER BY f.due_date ASC - LIMIT 10` + LIMIT 40` if err := s.db.SelectContext(ctx, &data.UpcomingDeadlines, query, user.ID, today, endOfWeek); err != nil { return fmt.Errorf("dashboard upcoming deadlines: %w", err) @@ -269,6 +321,45 @@ SELECT f.id, return nil } +// loadInboxSummary populates DashboardData.InboxSummary — the open- +// approval count + top InboxTopCap entries for the inbox-approvals +// widget (t-paliad-219). When ApprovalService is unwired (knowledge- +// platform-only deployments, tests), the function is a no-op and the +// widget renders its empty state. +func (s *DashboardService) loadInboxSummary(ctx context.Context, data *DashboardData, user *models.User) error { + data.InboxSummary = InboxSummary{Top: []InboxEntry{}} + if s.approvals == nil { + return nil + } + cnt, err := s.approvals.PendingCountForUser(ctx, user.ID) + if err != nil { + return fmt.Errorf("dashboard inbox count: %w", err) + } + data.InboxSummary.PendingCount = cnt + if cnt == 0 { + return nil + } + rows, err := s.approvals.ListPendingForApprover(ctx, user.ID, InboxFilter{Limit: InboxTopCap}) + if err != nil { + return fmt.Errorf("dashboard inbox top: %w", err) + } + top := make([]InboxEntry, 0, len(rows)) + for _, r := range rows { + top = append(top, InboxEntry{ + RequestID: r.ID, + EntityType: r.EntityType, + EntityTitle: r.EntityTitle, + ProjectID: r.ProjectID, + ProjectTitle: r.ProjectTitle, + RequestedAt: r.RequestedAt, + RequesterID: r.RequestedBy, + RequesterName: r.RequesterName, + }) + } + data.InboxSummary.Top = top + return nil +} + func (s *DashboardService) loadUpcomingAppointments(ctx context.Context, data *DashboardData, user *models.User, now time.Time) error { query := ` SELECT t.id, @@ -282,13 +373,13 @@ SELECT t.id, FROM paliad.appointments t LEFT JOIN paliad.projects p ON p.id = t.project_id WHERE t.start_at >= $2 - AND t.start_at < ($2 + interval '7 days') + AND t.start_at < ($2 + interval '60 days') 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 - LIMIT 10` + LIMIT 40` if err := s.db.SelectContext(ctx, &data.UpcomingAppointments, query, user.ID, now); err != nil { return fmt.Errorf("dashboard upcoming appointments: %w", err) diff --git a/internal/services/dashboard_service_test.go b/internal/services/dashboard_service_test.go new file mode 100644 index 0000000..dbe1855 --- /dev/null +++ b/internal/services/dashboard_service_test.go @@ -0,0 +1,51 @@ +package services + +// Pure-function tests for DashboardService extensions in Slice A3. + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "mgit.msbls.de/m/paliad/internal/models" +) + +func TestDashboardService_InboxSummary_NilApprovalsIsNoop(t *testing.T) { + s := &DashboardService{} // approvals nil + data := &DashboardData{} + user := &models.User{ID: uuid.New()} + if err := s.loadInboxSummary(context.Background(), data, user); err != nil { + t.Fatalf("loadInboxSummary with nil approvals returned %v; want nil", err) + } + if data.InboxSummary.PendingCount != 0 { + t.Errorf("PendingCount=%d; want 0", data.InboxSummary.PendingCount) + } + if data.InboxSummary.Top == nil { + t.Errorf("Top is nil; want empty slice") + } + if len(data.InboxSummary.Top) != 0 { + t.Errorf("Top has %d entries; want 0", len(data.InboxSummary.Top)) + } +} + +func TestDashboardService_SetApprovalService_WiringWorks(t *testing.T) { + s := &DashboardService{} + if s.approvals != nil { + t.Fatalf("freshly-constructed DashboardService has non-nil approvals") + } + a := &ApprovalService{} // empty shell; we only check the pointer wiring + s.SetApprovalService(a) + if s.approvals != a { + t.Errorf("SetApprovalService did not wire the pointer") + } +} + +func TestInboxTopCap_NonZero(t *testing.T) { + // Sanity guard: if someone zeros this const, the inbox-approvals + // widget falls back to an empty top-N silently. Pin it ≥ the + // largest catalog count option for the inbox widget (10). + if InboxTopCap < 10 { + t.Errorf("InboxTopCap=%d; must be ≥ 10 to satisfy widget catalog max count", InboxTopCap) + } +}