819 lines
26 KiB
Go
819 lines
26 KiB
Go
package services
|
|
|
|
// ViewService extension on EventService — runs a FilterSpec across the
|
|
// 4 substrate sources (deadline, appointment, project_event,
|
|
// approval_request) and returns a unified []ViewRow.
|
|
//
|
|
// Design: docs/design-data-display-model-2026-05-06.md §3 + §6.3.
|
|
//
|
|
// EventService is extended (not renamed) so the existing handlers
|
|
// (/api/events, /api/events/summary) keep working unchanged. New
|
|
// handlers (/api/views/{slug}/run, /api/user-views/...) call RunSpec.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// ViewRow is the unified row shape returned by RunSpec. Discriminated by
|
|
// `Kind`; type-specific fields live under `Detail` as a per-source struct
|
|
// marshalled via json.RawMessage.
|
|
type ViewRow struct {
|
|
Kind DataSource `json:"kind"`
|
|
ID uuid.UUID `json:"id"`
|
|
Title string `json:"title"`
|
|
|
|
// Subtitle: one short context line (e.g. "Frist", "Termin",
|
|
// "Genehmigung von …"). Optional; UIs render it under the title.
|
|
Subtitle *string `json:"subtitle,omitempty"`
|
|
|
|
// EventDate is the canonical sort key per row. Source-determined:
|
|
// - deadline: due_date at 00:00 UTC
|
|
// - appointment: start_at
|
|
// - project_event: created_at
|
|
// - approval_request: requested_at (or decided_at if status decided)
|
|
EventDate time.Time `json:"event_date"`
|
|
|
|
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
|
ProjectTitle *string `json:"project_title,omitempty"`
|
|
ProjectReference *string `json:"project_reference,omitempty"`
|
|
ProjectType *string `json:"project_type,omitempty"`
|
|
|
|
ActorID *uuid.UUID `json:"actor_id,omitempty"`
|
|
ActorName *string `json:"actor_name,omitempty"`
|
|
|
|
// Detail is the per-source typed payload as raw JSON. Frontend
|
|
// type-narrows on Kind and parses Detail accordingly.
|
|
Detail json.RawMessage `json:"detail"`
|
|
}
|
|
|
|
// ViewRunResult is the response shape of RunSpec — rows + a count of
|
|
// projects that contributed zero rows because the caller can't see them
|
|
// (Q17 fail-open attribution).
|
|
type ViewRunResult struct {
|
|
Rows []ViewRow `json:"rows"`
|
|
InaccessibleProjectIDs []uuid.UUID `json:"inaccessible_project_ids,omitempty"`
|
|
}
|
|
|
|
// RunSpec executes the FilterSpec against the substrate and returns
|
|
// merged rows sorted by EventDate (ascending for forward-looking,
|
|
// descending if any sort hint says so). Visibility is enforced via
|
|
// the per-source RLS predicates already used by the underlying tables;
|
|
// `userID` is the caller for context propagation.
|
|
//
|
|
// Caller has run spec.Validate() before us. We trust the spec.
|
|
func (s *EventService) RunSpec(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService) (*ViewRunResult, error) {
|
|
if approval == nil && slices.Contains(spec.Sources, SourceApprovalRequest) {
|
|
// Approval source requires the approval service. Return a clear
|
|
// error rather than silently skipping it — handlers always pass
|
|
// the bundle's approval service.
|
|
return nil, fmt.Errorf("RunSpec: approval source selected but ApprovalService is nil")
|
|
}
|
|
|
|
rows := make([]ViewRow, 0, 256)
|
|
bounds := computeViewSpecBounds(time.Now().UTC(), spec.Time)
|
|
|
|
if spec.UnreadOnly && approval != nil {
|
|
cursor, err := approval.InboxSeenAt(ctx, userID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("inbox cursor lookup: %w", err)
|
|
}
|
|
bounds.unreadOnly = true
|
|
bounds.inboxSeenAt = cursor
|
|
}
|
|
|
|
for _, src := range spec.Sources {
|
|
switch src {
|
|
case SourceDeadline:
|
|
batch, err := s.runDeadlines(ctx, userID, spec, bounds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows = append(rows, batch...)
|
|
|
|
case SourceAppointment:
|
|
batch, err := s.runAppointments(ctx, userID, spec, bounds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows = append(rows, batch...)
|
|
|
|
case SourceProjectEvent:
|
|
batch, err := s.runProjectEvents(ctx, userID, spec, bounds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows = append(rows, batch...)
|
|
|
|
case SourceApprovalRequest:
|
|
batch, err := s.runApprovalRequests(ctx, userID, spec, approval, bounds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rows = append(rows, batch...)
|
|
}
|
|
}
|
|
|
|
// Default sort: ascending. Per-source sort hints don't apply here —
|
|
// Render-side sort (RenderSpec.List/Cards.Sort) is the user-facing
|
|
// knob. We give the substrate a stable shape; the renderer flips it.
|
|
sort.SliceStable(rows, func(i, j int) bool {
|
|
if rows[i].EventDate.Equal(rows[j].EventDate) {
|
|
// Tiebreaker: kind alphabetical, then title — deterministic.
|
|
if rows[i].Kind != rows[j].Kind {
|
|
return rows[i].Kind < rows[j].Kind
|
|
}
|
|
return rows[i].Title < rows[j].Title
|
|
}
|
|
return rows[i].EventDate.Before(rows[j].EventDate)
|
|
})
|
|
|
|
out := &ViewRunResult{Rows: rows}
|
|
// Q17 fail-open attribution: if the caller specified explicit
|
|
// project IDs, surface the ones they couldn't see. We do that with
|
|
// one cheap check against can_see_project (via RLS-aware visibility
|
|
// predicate), batched per call.
|
|
if spec.Scope.Projects.Mode == ScopeExplicit {
|
|
inaccessible, err := s.filterInaccessibleProjects(ctx, userID, spec.Scope.Projects.IDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(inaccessible) > 0 {
|
|
out.InaccessibleProjectIDs = inaccessible
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// viewSpecBounds carries the resolved [from, to) window the spec
|
|
// translates into. Either bound can be nil (open-ended).
|
|
//
|
|
// inboxSeenAt is set by RunSpec when spec.UnreadOnly=true: the caller's
|
|
// users.inbox_seen_at cursor pre-resolved once so each source-runner can
|
|
// overlay it without re-querying the users table. nil means "never
|
|
// visited" — every row is unread.
|
|
type viewSpecBounds struct {
|
|
from *time.Time
|
|
to *time.Time
|
|
unreadOnly bool
|
|
inboxSeenAt *time.Time
|
|
}
|
|
|
|
func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
|
|
now = now.UTC()
|
|
day := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
tomorrow := day.AddDate(0, 0, 1)
|
|
switch ts.Horizon {
|
|
case HorizonNext1d:
|
|
from := day
|
|
to := day.AddDate(0, 0, 1)
|
|
return viewSpecBounds{from: &from, to: &to}
|
|
case HorizonNext7d:
|
|
from := day
|
|
to := day.AddDate(0, 0, 7)
|
|
return viewSpecBounds{from: &from, to: &to}
|
|
case HorizonNext14d:
|
|
from := day
|
|
to := day.AddDate(0, 0, 14)
|
|
return viewSpecBounds{from: &from, to: &to}
|
|
case HorizonNext30d:
|
|
from := day
|
|
to := day.AddDate(0, 0, 30)
|
|
return viewSpecBounds{from: &from, to: &to}
|
|
case HorizonNext90d:
|
|
from := day
|
|
to := day.AddDate(0, 0, 90)
|
|
return viewSpecBounds{from: &from, to: &to}
|
|
case HorizonNextAll:
|
|
// One-sided unbounded — from today onwards, no upper bound.
|
|
// Distinct from HorizonAll (bidirectional unbounded) and
|
|
// HorizonAny (no time filter at all).
|
|
from := day
|
|
return viewSpecBounds{from: &from}
|
|
case HorizonPast1d:
|
|
from := day.AddDate(0, 0, -1)
|
|
return viewSpecBounds{from: &from, to: &tomorrow}
|
|
case HorizonPast7d:
|
|
from := day.AddDate(0, 0, -7)
|
|
return viewSpecBounds{from: &from, to: &tomorrow}
|
|
case HorizonPast14d:
|
|
from := day.AddDate(0, 0, -14)
|
|
return viewSpecBounds{from: &from, to: &tomorrow}
|
|
case HorizonPast30d:
|
|
from := day.AddDate(0, 0, -30)
|
|
return viewSpecBounds{from: &from, to: &tomorrow}
|
|
case HorizonPast90d:
|
|
from := day.AddDate(0, 0, -90)
|
|
return viewSpecBounds{from: &from, to: &tomorrow}
|
|
case HorizonPastAll:
|
|
// One-sided unbounded — up to and including today, no lower bound.
|
|
return viewSpecBounds{to: &tomorrow}
|
|
case HorizonAny, HorizonAll:
|
|
return viewSpecBounds{}
|
|
case HorizonCustom:
|
|
return viewSpecBounds{from: ts.From, to: ts.To}
|
|
}
|
|
return viewSpecBounds{}
|
|
}
|
|
|
|
// runDeadlines projects DeadlineWithProject rows from the existing
|
|
// DeadlineService.ListVisibleForUser onto ViewRow, applying spec narrowing.
|
|
func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
|
|
df := ListFilter{}
|
|
if spec.Scope.PersonalOnly {
|
|
uid := userID
|
|
df.CreatedBy = &uid
|
|
}
|
|
if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil {
|
|
dp := preds.Deadline
|
|
// Status: ListFilter has DeadlineStatusFilter (single-value filter).
|
|
// If the spec asks for both pending+completed → no narrowing; if
|
|
// only pending → DeadlineFilterPending; only completed → Completed.
|
|
switch {
|
|
case len(dp.Status) == 1 && dp.Status[0] == "pending":
|
|
df.Status = DeadlineFilterPending
|
|
case len(dp.Status) == 1 && dp.Status[0] == "completed":
|
|
df.Status = DeadlineFilterCompleted
|
|
default:
|
|
df.Status = DeadlineFilterAll
|
|
}
|
|
df.EventTypeIDs = dp.EventTypeIDs
|
|
df.IncludeUntyped = dp.IncludeUntyped
|
|
}
|
|
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
|
|
// DeadlineService takes one project id; we filter post-load when
|
|
// spec selects multiple projects (the visibility predicate already
|
|
// bounds to the caller's set, and explicit IDs are a refinement).
|
|
}
|
|
|
|
rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]ViewRow, 0, len(rows))
|
|
allowedProjects := explicitProjectSet(spec)
|
|
|
|
for _, r := range rows {
|
|
eventDate := time.Date(r.DueDate.Year(), r.DueDate.Month(), r.DueDate.Day(), 0, 0, 0, 0, time.UTC)
|
|
if !inSpecWindow(eventDate, bounds) {
|
|
continue
|
|
}
|
|
if allowedProjects != nil && !allowedProjects[r.ProjectID] {
|
|
continue
|
|
}
|
|
// Approval-status narrowing (entity-side pill).
|
|
if !approvalStatusMatches(r.ApprovalStatus, spec, SourceDeadline) {
|
|
continue
|
|
}
|
|
|
|
detail, _ := json.Marshal(map[string]any{
|
|
"due_date": r.DueDate.Format("2006-01-02"),
|
|
"status": r.Status,
|
|
"approval_status": r.ApprovalStatus,
|
|
"source": r.Source,
|
|
"rule_id": r.RuleID,
|
|
"rule_code": r.RuleCode,
|
|
"rule_name": r.RuleName,
|
|
"event_type_ids": r.EventTypeIDs,
|
|
"description": r.Description,
|
|
"completed_at": r.CompletedAt,
|
|
})
|
|
pid := r.ProjectID
|
|
pt := r.ProjectTitle
|
|
ptype := r.ProjectType
|
|
out = append(out, ViewRow{
|
|
Kind: SourceDeadline,
|
|
ID: r.ID,
|
|
Title: r.Title,
|
|
EventDate: eventDate,
|
|
ProjectID: &pid,
|
|
ProjectTitle: &pt,
|
|
ProjectReference: r.ProjectReference,
|
|
ProjectType: &ptype,
|
|
ActorID: r.CreatedBy,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// runAppointments projects AppointmentWithProject onto ViewRow.
|
|
func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
|
|
af := AppointmentListFilter{}
|
|
if spec.Scope.PersonalOnly {
|
|
uid := userID
|
|
af.CreatedBy = &uid
|
|
}
|
|
af.From = bounds.from
|
|
af.To = bounds.to
|
|
if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil {
|
|
ap := preds.Appointment
|
|
// AppointmentListFilter takes a single Type today; narrow to first
|
|
// listed value, fall back to all if multiple.
|
|
if len(ap.AppointmentTypes) == 1 {
|
|
t := ap.AppointmentTypes[0]
|
|
af.Type = &t
|
|
}
|
|
}
|
|
|
|
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]ViewRow, 0, len(rows))
|
|
allowedProjects := explicitProjectSet(spec)
|
|
allowedTypes := allowedAppointmentTypes(spec)
|
|
|
|
for _, r := range rows {
|
|
if !inSpecWindow(r.StartAt, bounds) {
|
|
continue
|
|
}
|
|
if r.ProjectID != nil && allowedProjects != nil && !allowedProjects[*r.ProjectID] {
|
|
continue
|
|
}
|
|
if r.ProjectID == nil && allowedProjects != nil {
|
|
continue
|
|
}
|
|
if !approvalStatusMatches(r.ApprovalStatus, spec, SourceAppointment) {
|
|
continue
|
|
}
|
|
if allowedTypes != nil {
|
|
if r.AppointmentType == nil || !allowedTypes[*r.AppointmentType] {
|
|
continue
|
|
}
|
|
}
|
|
|
|
detail, _ := json.Marshal(map[string]any{
|
|
"start_at": r.StartAt,
|
|
"end_at": r.EndAt,
|
|
"location": r.Location,
|
|
"appointment_type": r.AppointmentType,
|
|
"approval_status": r.ApprovalStatus,
|
|
"description": r.Description,
|
|
})
|
|
out = append(out, ViewRow{
|
|
Kind: SourceAppointment,
|
|
ID: r.ID,
|
|
Title: r.Title,
|
|
EventDate: r.StartAt,
|
|
ProjectID: r.ProjectID,
|
|
ProjectTitle: r.ProjectTitle,
|
|
ProjectReference: r.ProjectReference,
|
|
ProjectType: r.ProjectType,
|
|
ActorID: r.CreatedBy,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// runProjectEvents queries paliad.project_events with the visibility
|
|
// predicate. The audit table doesn't have a service wrapper today; we
|
|
// run our own SQL bounded by the spec.
|
|
//
|
|
// Inbox semantics (t-paliad-249) kick in when bounds.unreadOnly is set:
|
|
// the caller's own events are excluded (you don't notify yourself) and
|
|
// rows older than bounds.inboxSeenAt are dropped. Cursor lookup happens
|
|
// once in RunSpec — runProjectEvents only consumes the resolved value.
|
|
func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, spec FilterSpec, bounds viewSpecBounds) ([]ViewRow, error) {
|
|
conds := []string{visibilityPredicatePositional("p", 1)}
|
|
args := []any{userID}
|
|
|
|
allowedKinds := allowedProjectEventKinds(spec)
|
|
if len(allowedKinds) > 0 {
|
|
args = append(args, pq.Array(allowedKinds))
|
|
conds = append(conds, fmt.Sprintf("pe.event_type = ANY($%d)", len(args)))
|
|
}
|
|
if bounds.from != nil {
|
|
args = append(args, *bounds.from)
|
|
conds = append(conds, fmt.Sprintf("pe.created_at >= $%d", len(args)))
|
|
}
|
|
if bounds.to != nil {
|
|
args = append(args, *bounds.to)
|
|
conds = append(conds, fmt.Sprintf("pe.created_at < $%d", len(args)))
|
|
}
|
|
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) > 0 {
|
|
args = append(args, spec.Scope.Projects.IDs)
|
|
conds = append(conds, fmt.Sprintf("pe.project_id = ANY($%d)", len(args)))
|
|
}
|
|
if bounds.unreadOnly {
|
|
// Inbox mode: hide the caller's own actions (no self-notify).
|
|
conds = append(conds, "pe.created_by IS DISTINCT FROM $1")
|
|
if bounds.inboxSeenAt != nil {
|
|
args = append(args, *bounds.inboxSeenAt)
|
|
conds = append(conds, fmt.Sprintf("pe.created_at > $%d", len(args)))
|
|
}
|
|
}
|
|
|
|
q := `
|
|
SELECT pe.id, pe.project_id, pe.event_type, pe.title, pe.description,
|
|
pe.event_date, pe.created_by, pe.created_at,
|
|
p.title AS project_title,
|
|
p.type AS project_type,
|
|
p.reference AS project_reference,
|
|
u.display_name AS actor_name
|
|
FROM paliad.project_events pe
|
|
JOIN paliad.projects p ON p.id = pe.project_id
|
|
LEFT JOIN paliad.users u ON u.id = pe.created_by
|
|
WHERE ` + strings.Join(conds, " AND ") + `
|
|
ORDER BY pe.created_at DESC
|
|
LIMIT 500`
|
|
|
|
type row struct {
|
|
ID uuid.UUID `db:"id"`
|
|
ProjectID uuid.UUID `db:"project_id"`
|
|
EventType *string `db:"event_type"`
|
|
Title string `db:"title"`
|
|
Description *string `db:"description"`
|
|
EventDate *time.Time `db:"event_date"`
|
|
CreatedBy *uuid.UUID `db:"created_by"`
|
|
CreatedAt time.Time `db:"created_at"`
|
|
ProjectTitle string `db:"project_title"`
|
|
ProjectType string `db:"project_type"`
|
|
ProjectReference *string `db:"project_reference"`
|
|
ActorName *string `db:"actor_name"`
|
|
}
|
|
|
|
var dbRows []row
|
|
if err := s.db.SelectContext(ctx, &dbRows, q, args...); err != nil {
|
|
return nil, fmt.Errorf("project_events query: %w", err)
|
|
}
|
|
|
|
out := make([]ViewRow, 0, len(dbRows))
|
|
for _, r := range dbRows {
|
|
detail, _ := json.Marshal(map[string]any{
|
|
"event_type": r.EventType,
|
|
"description": r.Description,
|
|
"event_date": r.EventDate,
|
|
})
|
|
pid := r.ProjectID
|
|
pt := r.ProjectTitle
|
|
ptype := r.ProjectType
|
|
out = append(out, ViewRow{
|
|
Kind: SourceProjectEvent,
|
|
ID: r.ID,
|
|
Title: r.Title,
|
|
EventDate: r.CreatedAt,
|
|
ProjectID: &pid,
|
|
ProjectTitle: &pt,
|
|
ProjectReference: r.ProjectReference,
|
|
ProjectType: &ptype,
|
|
ActorID: r.CreatedBy,
|
|
ActorName: r.ActorName,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// runApprovalRequests projects approval_request rows via the existing
|
|
// ApprovalService inbox queries. ViewerRole picks which underlying
|
|
// query runs.
|
|
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
|
|
preds := spec.Predicates[SourceApprovalRequest]
|
|
role := "approver_eligible"
|
|
if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" {
|
|
role = preds.ApprovalRequest.ViewerRole
|
|
}
|
|
|
|
filter := InboxFilter{}
|
|
if preds.ApprovalRequest != nil {
|
|
// InboxFilter takes a single status today. If the spec says
|
|
// only one, narrow; if multiple, leave open.
|
|
if len(preds.ApprovalRequest.Status) == 1 {
|
|
filter.Status = preds.ApprovalRequest.Status[0]
|
|
}
|
|
if len(preds.ApprovalRequest.EntityTypes) == 1 {
|
|
filter.EntityType = preds.ApprovalRequest.EntityTypes[0]
|
|
}
|
|
}
|
|
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
|
|
pid := spec.Scope.Projects.IDs[0]
|
|
filter.ProjectID = &pid
|
|
}
|
|
|
|
var rows []ApprovalRequestView
|
|
var err error
|
|
switch role {
|
|
case "approver_eligible":
|
|
rows, err = approval.ListPendingForApprover(ctx, userID, filter)
|
|
case "self_requested":
|
|
rows, err = approval.ListSubmittedByUser(ctx, userID, filter)
|
|
case "any_visible":
|
|
// any_visible is the broadest read — RLS bounds it. The existing
|
|
// ApprovalService doesn't have a "list all visible" call; we
|
|
// approximate by running both inbox queries and de-duping. Future
|
|
// optimization: dedicated service method.
|
|
a, errA := approval.ListPendingForApprover(ctx, userID, filter)
|
|
if errA != nil {
|
|
return nil, errA
|
|
}
|
|
b, errB := approval.ListSubmittedByUser(ctx, userID, filter)
|
|
if errB != nil {
|
|
return nil, errB
|
|
}
|
|
seen := make(map[uuid.UUID]bool, len(a)+len(b))
|
|
for _, r := range a {
|
|
if !seen[r.ID] {
|
|
rows = append(rows, r)
|
|
seen[r.ID] = true
|
|
}
|
|
}
|
|
for _, r := range b {
|
|
if !seen[r.ID] {
|
|
rows = append(rows, r)
|
|
seen[r.ID] = true
|
|
}
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("%w: approval_request.viewer_role %q", ErrInvalidInput, role)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
out := make([]ViewRow, 0, len(rows))
|
|
allowedStatuses := allowedRequestStatuses(spec)
|
|
allowedEntityTypes := allowedRequestEntityTypes(spec)
|
|
allowedProjects := explicitProjectSet(spec)
|
|
|
|
for _, r := range rows {
|
|
// Spec status filter (when the inbox query received broad results).
|
|
if allowedStatuses != nil && !allowedStatuses[r.Status] {
|
|
continue
|
|
}
|
|
if allowedEntityTypes != nil && !allowedEntityTypes[r.EntityType] {
|
|
continue
|
|
}
|
|
if allowedProjects != nil && !allowedProjects[r.ProjectID] {
|
|
continue
|
|
}
|
|
|
|
// Sort key: decided_at if decided, else requested_at.
|
|
eventDate := r.RequestedAt
|
|
if r.DecidedAt != nil {
|
|
eventDate = *r.DecidedAt
|
|
}
|
|
if !inSpecWindow(eventDate, bounds) {
|
|
continue
|
|
}
|
|
|
|
// Inbox unread-only carve-out (t-paliad-249, design §3):
|
|
// pending requests always survive; decided rows are subject to
|
|
// the cursor like any other audit-style item.
|
|
if bounds.unreadOnly && r.Status != RequestStatusPending {
|
|
if bounds.inboxSeenAt != nil && !eventDate.After(*bounds.inboxSeenAt) {
|
|
continue
|
|
}
|
|
}
|
|
|
|
title := approvalRowTitle(r)
|
|
subtitle := approvalRowSubtitle(r)
|
|
detail, _ := json.Marshal(r) // request view already carries everything the UI needs
|
|
actorID := r.RequestedBy
|
|
actorName := r.RequesterName
|
|
pid := r.ProjectID
|
|
pt := r.ProjectTitle
|
|
out = append(out, ViewRow{
|
|
Kind: SourceApprovalRequest,
|
|
ID: r.ID,
|
|
Title: title,
|
|
Subtitle: &subtitle,
|
|
EventDate: eventDate,
|
|
ProjectID: &pid,
|
|
ProjectTitle: &pt,
|
|
ActorID: &actorID,
|
|
ActorName: &actorName,
|
|
Detail: detail,
|
|
})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// approvalRowTitle returns a one-line title describing the approval
|
|
// request — used as the ViewRow.Title.
|
|
func approvalRowTitle(r ApprovalRequestView) string {
|
|
if r.EntityTitle != nil && *r.EntityTitle != "" {
|
|
return *r.EntityTitle
|
|
}
|
|
return fmt.Sprintf("%s %s", r.EntityType, r.LifecycleEvent)
|
|
}
|
|
|
|
// approvalRowSubtitle returns a one-line context for the request.
|
|
func approvalRowSubtitle(r ApprovalRequestView) string {
|
|
switch r.Status {
|
|
case "pending":
|
|
return fmt.Sprintf("Genehmigung angefragt von %s", r.RequesterName)
|
|
case "approved":
|
|
if r.DeciderName != nil {
|
|
return fmt.Sprintf("Genehmigt von %s", *r.DeciderName)
|
|
}
|
|
return "Genehmigt"
|
|
case "rejected":
|
|
if r.DeciderName != nil {
|
|
return fmt.Sprintf("Abgelehnt von %s", *r.DeciderName)
|
|
}
|
|
return "Abgelehnt"
|
|
case "revoked":
|
|
return "Widerrufen"
|
|
case "changes_requested":
|
|
if r.DeciderName != nil {
|
|
return fmt.Sprintf("Abgelehnt mit Vorschlag von %s", *r.DeciderName)
|
|
}
|
|
return "Abgelehnt mit Vorschlag"
|
|
}
|
|
return r.Status
|
|
}
|
|
|
|
// inSpecWindow returns true when ts is within [from, to). nil bounds
|
|
// are open-ended.
|
|
func inSpecWindow(ts time.Time, b viewSpecBounds) bool {
|
|
if b.from != nil && ts.Before(*b.from) {
|
|
return false
|
|
}
|
|
if b.to != nil && !ts.Before(*b.to) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// explicitProjectSet returns nil when the scope isn't explicit, otherwise
|
|
// a set membership map for fast filtering.
|
|
func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
|
|
if spec.Scope.Projects.Mode != ScopeExplicit {
|
|
return nil
|
|
}
|
|
out := make(map[uuid.UUID]bool, len(spec.Scope.Projects.IDs))
|
|
for _, id := range spec.Scope.Projects.IDs {
|
|
out[id] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// approvalStatusMatches checks the entity-side approval_status filter.
|
|
// Returns true when the row passes (no filter set → always true).
|
|
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
|
|
preds, ok := spec.Predicates[src]
|
|
if !ok {
|
|
return true
|
|
}
|
|
var allowed []string
|
|
switch src {
|
|
case SourceDeadline:
|
|
if preds.Deadline != nil {
|
|
allowed = preds.Deadline.ApprovalStatus
|
|
}
|
|
case SourceAppointment:
|
|
if preds.Appointment != nil {
|
|
allowed = preds.Appointment.ApprovalStatus
|
|
}
|
|
}
|
|
if len(allowed) == 0 {
|
|
return true
|
|
}
|
|
return slices.Contains(allowed, rowStatus)
|
|
}
|
|
|
|
// allowedAppointmentTypes returns nil when the filter is open, otherwise
|
|
// a set of legal appointment_type values.
|
|
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
|
|
preds, ok := spec.Predicates[SourceAppointment]
|
|
if !ok || preds.Appointment == nil {
|
|
return nil
|
|
}
|
|
if len(preds.Appointment.AppointmentTypes) <= 1 {
|
|
return nil // single-value already pushed down via AppointmentListFilter.Type
|
|
}
|
|
out := make(map[string]bool, len(preds.Appointment.AppointmentTypes))
|
|
for _, t := range preds.Appointment.AppointmentTypes {
|
|
out[t] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// allowedProjectEventKinds returns the slice of project_event.event_type
|
|
// values the spec narrows to, or nil for "all known kinds".
|
|
//
|
|
// Inbox de-dup (t-paliad-249): when the spec also fans out
|
|
// SourceApprovalRequest, every `*_approval_*` audit event is dropped
|
|
// — the approval_request row itself is the canonical signal, and we
|
|
// don't want both rows showing up side-by-side. The drop applies to
|
|
// both the explicit caller list and the implicit "all kinds" path.
|
|
func allowedProjectEventKinds(spec FilterSpec) []string {
|
|
preds, ok := spec.Predicates[SourceProjectEvent]
|
|
dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest)
|
|
|
|
var requested []string
|
|
switch {
|
|
case ok && preds.ProjectEvent != nil && len(preds.ProjectEvent.EventTypes) > 0:
|
|
requested = preds.ProjectEvent.EventTypes
|
|
case dedupApprovals:
|
|
// No explicit narrowing, but ApprovalRequest is in sources —
|
|
// rebuild the implicit "all" list so we can subtract approvals.
|
|
requested = KnownProjectEventKinds
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
if !dedupApprovals {
|
|
return requested
|
|
}
|
|
filtered := make([]string, 0, len(requested))
|
|
for _, k := range requested {
|
|
if isApprovalAuditKind(k) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, k)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// isApprovalAuditKind matches the `*_approval_*` audit event_types that
|
|
// every approval mutation emits alongside the approval_request row.
|
|
// Dropped from inbox project_event reads (see allowedProjectEventKinds).
|
|
func isApprovalAuditKind(kind string) bool {
|
|
return strings.Contains(kind, "_approval_") || kind == "approval_decided"
|
|
}
|
|
|
|
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
|
|
// already pushed into InboxFilter.Status").
|
|
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
|
|
preds, ok := spec.Predicates[SourceApprovalRequest]
|
|
if !ok || preds.ApprovalRequest == nil {
|
|
return nil
|
|
}
|
|
if len(preds.ApprovalRequest.Status) <= 1 {
|
|
return nil
|
|
}
|
|
out := make(map[string]bool, len(preds.ApprovalRequest.Status))
|
|
for _, s := range preds.ApprovalRequest.Status {
|
|
out[s] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
|
|
preds, ok := spec.Predicates[SourceApprovalRequest]
|
|
if !ok || preds.ApprovalRequest == nil {
|
|
return nil
|
|
}
|
|
if len(preds.ApprovalRequest.EntityTypes) <= 1 {
|
|
return nil
|
|
}
|
|
out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes))
|
|
for _, t := range preds.ApprovalRequest.EntityTypes {
|
|
out[t] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
// filterInaccessibleProjects returns the subset of `requested` that the
|
|
// caller cannot see. Implementation: SELECT id FROM paliad.projects
|
|
// WHERE id = ANY(...) (RLS filters the visible ones); the missing ones
|
|
// are inaccessible. One DB hit per RunSpec when scope is explicit.
|
|
func (s *EventService) filterInaccessibleProjects(ctx context.Context, userID uuid.UUID, requested []uuid.UUID) ([]uuid.UUID, error) {
|
|
if len(requested) == 0 {
|
|
return nil, nil
|
|
}
|
|
q := `SELECT p.id
|
|
FROM paliad.projects p
|
|
WHERE p.id = ANY($1)
|
|
AND ` + visibilityPredicatePositional("p", 2)
|
|
var visible []uuid.UUID
|
|
if err := s.db.SelectContext(ctx, &visible, q, requested, userID); err != nil {
|
|
return nil, fmt.Errorf("filter inaccessible projects: %w", err)
|
|
}
|
|
visibleSet := make(map[uuid.UUID]bool, len(visible))
|
|
for _, id := range visible {
|
|
visibleSet[id] = true
|
|
}
|
|
out := make([]uuid.UUID, 0)
|
|
for _, id := range requested {
|
|
if !visibleSet[id] {
|
|
out = append(out, id)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Compile-time guards: the substrate's source loaders read fields off
|
|
// known model shapes. If a model rename breaks this, the build fails
|
|
// here rather than at runtime in production.
|
|
var (
|
|
_ = models.DeadlineWithProject{}
|
|
_ = models.AppointmentWithProject{}
|
|
_ = models.ProjectEvent{}
|
|
)
|