Files
paliad/internal/services/view_service.go
2026-05-25 15:51:36 +02:00

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{}
)