diff --git a/frontend/src/client/events.ts b/frontend/src/client/events.ts index 265acb2..47fd57e 100644 --- a/frontend/src/client/events.ts +++ b/frontend/src/client/events.ts @@ -303,7 +303,12 @@ async function loadSummary() { try { const params = new URLSearchParams(); params.set("type", currentType); - if (projectFilter && projectFilter !== PERSONAL) { + if (projectFilter === PERSONAL) { + // "Nur persönliche" → items the caller created. The backend + // applies this uniformly to both rails (deadlines + appointments) + // and ignores any project_id we'd send. See t-paliad-128. + params.set("personal_only", "true"); + } else if (projectFilter) { params.set("project_id", projectFilter); } const resp = await fetch(`/api/events/summary?${params.toString()}`); @@ -360,7 +365,12 @@ async function loadList() { // no-op on the appointment side. params.set("status", statusFilter); } - if (projectFilter && projectFilter !== PERSONAL) { + if (projectFilter === PERSONAL) { + // "Nur persönliche" → items the caller created (t-paliad-128). + // Applies uniformly to both rails server-side; project_id is + // intentionally not sent (the two are mutually exclusive). + params.set("personal_only", "true"); + } else if (projectFilter) { params.set("project_id", projectFilter); } if (currentType !== "appointment") { @@ -382,13 +392,7 @@ async function loadList() { hideTableAndCalendar(); return; } - let data: EventListItem[] = await resp.json(); - // "Personal only" is a UI shorthand for project_id IS NULL on the - // appointment side. Filter client-side because the backend doesn't - // expose a NULL-id query (matches current /appointments behaviour). - if (projectFilter === PERSONAL) { - data = data.filter((x) => x.type === "appointment" && !x.project_id); - } + const data: EventListItem[] = await resp.json(); allItems = data; loadedOK = true; render(); @@ -772,19 +776,8 @@ function applyTypeVisibility() { // Termin-Typ is appointment-only. toggleFilterPair("events-filter-appointment-type", !isDeadline); - // "Personal only" project option only makes sense with appointments in scope. - const projectSel = document.getElementById("events-filter-project") as HTMLSelectElement | null; - if (projectSel) { - const personalOpt = projectSel.querySelector('option[value="__personal__"]'); - if (personalOpt) { - personalOpt.hidden = isDeadline; - personalOpt.disabled = isDeadline; - if (isDeadline && projectFilter === PERSONAL) { - projectFilter = ""; - projectSel.value = ""; - } - } - } + // "Nur persönliche" applies uniformly to deadlines + appointments now + // (t-paliad-128) — items where the caller is the creator. Always available. // Update chip active state. document.querySelectorAll('[data-event-type]').forEach((b) => { @@ -836,6 +829,7 @@ function syncURLParams() { url.searchParams.delete("view"); url.searchParams.delete("status"); url.searchParams.delete("project_id"); + url.searchParams.delete("personal_only"); url.searchParams.delete("event_type"); url.searchParams.delete("type_filter"); // Default type per route comes from window.__PALIAD_EVENTS__; only @@ -907,6 +901,9 @@ function initFilters() { statusFilter = defaultStatusFor(currentType); } if (params.has("project_id")) projectFilter = params.get("project_id")!; + // ?personal_only=true is a bookmark-friendly alias for project_id=__personal__. + // Both URLs land on the same UI state (PERSONAL sentinel in the project select). + if (params.get("personal_only") === "true") projectFilter = PERSONAL; if (params.has("type_filter")) appointmentTypeFilter = params.get("type_filter")!; const status = document.getElementById("events-filter-status") as HTMLSelectElement; diff --git a/internal/handlers/events.go b/internal/handlers/events.go index e659818..996c274 100644 --- a/internal/handlers/events.go +++ b/internal/handlers/events.go @@ -8,19 +8,23 @@ import ( "mgit.msbls.de/m/paliad/internal/services" ) -// GET /api/events?type=deadline|appointment|all&status=…&project_id=…&event_type=…&type_filter=…&from=…&to=… +// GET /api/events?type=deadline|appointment|all&status=…&project_id=…&event_type=…&type_filter=…&from=…&to=…&personal_only=true // -// type — discriminator for the union; default "all" (Beides on the -// front-end). When "deadline" or "appointment", the matching -// type-specific filters take effect; the others are ignored. -// status — DeadlineStatusFilter (deadline-only). -// project_id — single project scope. -// event_type — comma-separated event_type uuids; the literal "none" -// toggles include-untyped (deadlines only). -// type_filter — appointment_type filter (hearing/meeting/...). Named -// `type_filter` in the query string to avoid clashing with -// the `type` discriminator above. -// from / to — date window applied to the canonical event_date. +// type — discriminator for the union; default "all" (Beides on the +// front-end). When "deadline" or "appointment", the matching +// type-specific filters take effect; the others are ignored. +// status — DeadlineStatusFilter (deadline-only on the deadline rail; +// bucket values also narrow the appointment rail). +// project_id — single project scope. Ignored when personal_only=true. +// event_type — comma-separated event_type uuids; the literal "none" +// toggles include-untyped (deadlines only). +// type_filter — appointment_type filter (hearing/meeting/...). Named +// `type_filter` in the query string to avoid clashing with +// the `type` discriminator above. +// from / to — date window applied to the canonical event_date. +// personal_only — narrow BOTH rails to rows the caller created +// (t-paliad-128). Mutually exclusive with project_id — +// if both are sent, project_id is ignored. func handleListEvents(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -32,8 +36,9 @@ func handleListEvents(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() filter := services.EventListFilter{ - Type: parseEventTypeDiscriminator(q.Get("type")), - Status: services.DeadlineStatusFilter(q.Get("status")), + Type: parseEventTypeDiscriminator(q.Get("type")), + Status: services.DeadlineStatusFilter(q.Get("status")), + PersonalOnly: parseBoolFlag(q.Get("personal_only")), } if raw := q.Get("project_id"); raw != "" { @@ -81,7 +86,7 @@ func handleListEvents(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rows) } -// GET /api/events/summary?type=deadline|appointment|all&project_id=… +// GET /api/events/summary?type=deadline|appointment|all&project_id=…&personal_only=true func handleEventsSummary(w http.ResponseWriter, r *http.Request) { if !requireDB(w) { return @@ -93,7 +98,8 @@ func handleEventsSummary(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() filter := services.EventSummaryFilter{ - Type: parseEventTypeDiscriminator(q.Get("type")), + Type: parseEventTypeDiscriminator(q.Get("type")), + PersonalOnly: parseBoolFlag(q.Get("personal_only")), } if raw := q.Get("project_id"); raw != "" { projectID, err := uuid.Parse(raw) @@ -125,3 +131,14 @@ func parseEventTypeDiscriminator(raw string) services.EventTypeFilter { return services.EventTypeAll } } + +// parseBoolFlag accepts the common truthy spellings used in URL flags. +// Anything else (including the empty string) is false, matching Go's +// usual permissive treatment of optional bool query params. +func parseBoolFlag(raw string) bool { + switch raw { + case "true", "1", "yes", "on": + return true + } + return false +} diff --git a/internal/services/appointment_service.go b/internal/services/appointment_service.go index e40b080..c301370 100644 --- a/internal/services/appointment_service.go +++ b/internal/services/appointment_service.go @@ -76,11 +76,18 @@ type UpdateAppointmentInput struct { } // AppointmentListFilter narrows ListVisibleForUser results. +// +// CreatedBy narrows to appointments whose `created_by = id`. Backs the +// "Nur persönliche" filter on /events (t-paliad-128). It composes with +// the team-visibility predicate (AND) rather than replacing it, so an +// appointment a user created on a team they have since left still +// won't leak through. type AppointmentListFilter struct { ProjectID *uuid.UUID From *time.Time To *time.Time Type *string + CreatedBy *uuid.UUID } // ListVisibleForUser returns all Appointments the user can see (personal + @@ -122,6 +129,10 @@ func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid conds = append(conds, `t.appointment_type = :type`) args["type"] = *filter.Type } + if filter.CreatedBy != nil { + conds = append(conds, `t.created_by = :created_by`) + args["created_by"] = *filter.CreatedBy + } query := ` SELECT t.id, t.project_id, t.title, t.description, t.start_at, t.end_at, diff --git a/internal/services/deadline_service.go b/internal/services/deadline_service.go index 2d54eb8..4a66ea0 100644 --- a/internal/services/deadline_service.go +++ b/internal/services/deadline_service.go @@ -101,11 +101,17 @@ const ( // a deadline matches if it has at least one of EventTypeIDs attached // OR (IncludeUntyped && it has none). When BOTH are zero/false the // filter is inactive. +// +// CreatedBy narrows to deadlines whose `created_by = id`. Backs the +// "Nur persönliche" filter on /events (t-paliad-128) — applied on top of +// the team-visibility predicate so a deadline a user created on a team +// they have since left still doesn't leak through. type ListFilter struct { Status DeadlineStatusFilter ProjectID *uuid.UUID EventTypeIDs []uuid.UUID IncludeUntyped bool + CreatedBy *uuid.UUID } // ListVisibleForUser returns Deadlines on every Project the user can see, @@ -127,6 +133,10 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU conds = append(conds, projectDescendantPredicate("p")) args["project_id"] = *filter.ProjectID } + if filter.CreatedBy != nil { + conds = append(conds, `f.created_by = :created_by`) + args["created_by"] = *filter.CreatedBy + } if etCond := buildEventTypeFilterClause(filter, args); etCond != "" { conds = append(conds, etCond) } diff --git a/internal/services/event_service.go b/internal/services/event_service.go index 849861c..ebc254b 100644 --- a/internal/services/event_service.go +++ b/internal/services/event_service.go @@ -50,6 +50,11 @@ func NewEventService(db *sqlx.DB, deadlines *DeadlineService, appointments *Appo // EventListFilter narrows ListVisibleForUser. Most fields are type-specific; // passing them with Type=Appointment (or vice versa) is a no-op rather than // an error so the handler can stay shape-stable across type switches. +// +// PersonalOnly narrows BOTH rails to rows whose `created_by` matches the +// caller — backs the "Nur persönliche" filter on /events (t-paliad-128). +// When set, ProjectID is ignored (the two are contradictory: personal +// means "items I created", not "items in some project I picked"). type EventListFilter struct { Type EventTypeFilter @@ -60,9 +65,10 @@ type EventListFilter struct { AppointmentType *string // Common. - ProjectID *uuid.UUID - From *time.Time - To *time.Time + ProjectID *uuid.UUID + From *time.Time + To *time.Time + PersonalOnly bool } // EventListItem is one row of the unified events list. Type-specific @@ -113,6 +119,11 @@ func (s *EventService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, EventTypeIDs: filter.EventTypeIDs, IncludeUntyped: filter.IncludeUntyped, } + if filter.PersonalOnly { + uid := userID + df.CreatedBy = &uid + df.ProjectID = nil + } rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df) if err != nil { return nil, err @@ -142,6 +153,11 @@ func (s *EventService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, To: filter.To, Type: filter.AppointmentType, } + if filter.PersonalOnly { + uid := userID + af.CreatedBy = &uid + af.ProjectID = nil + } bounds := computeDeadlineBucketBounds(time.Now().UTC()) from, to := bucketAppointmentWindow(filter.Status, bounds) af.From = pickLater(af.From, from) @@ -301,12 +317,18 @@ func inDateWindow(due time.Time, from, to *time.Time) bool { return true } -// EventSummaryFilter narrows SummaryCounts. Today only `Type` and -// `ProjectID` matter; status/event_type filters intentionally don't shape -// the bucket counts (the cards are global "what's coming?" indicators). +// EventSummaryFilter narrows SummaryCounts. Today only `Type`, +// `ProjectID`, and `PersonalOnly` matter; status/event_type filters +// intentionally don't shape the bucket counts (the cards are global +// "what's coming?" indicators). +// +// PersonalOnly mirrors EventListFilter.PersonalOnly — narrows both +// bucket queries to rows the caller created. ProjectID is ignored when +// PersonalOnly is set. type EventSummaryFilter struct { - Type EventTypeFilter - ProjectID *uuid.UUID + Type EventTypeFilter + ProjectID *uuid.UUID + PersonalOnly bool } // EventSummary is the response shape of /api/events/summary. Either side @@ -377,8 +399,15 @@ func (s *EventService) SummaryCounts(ctx context.Context, userID uuid.UUID, filt bounds := computeDeadlineBucketBounds(time.Now().UTC()) + projectID := filter.ProjectID + if filter.PersonalOnly { + // PersonalOnly is mutually exclusive with ProjectID — see the + // EventSummaryFilter doc comment. + projectID = nil + } + if wantDeadlines { - buckets, err := s.deadlineBuckets(ctx, userID, filter.ProjectID, bounds) + buckets, err := s.deadlineBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly) if err != nil { return nil, err } @@ -386,7 +415,7 @@ func (s *EventService) SummaryCounts(ctx context.Context, userID uuid.UUID, filt } if wantAppointments { - buckets, err := s.appointmentBuckets(ctx, userID, filter.ProjectID, bounds) + buckets, err := s.appointmentBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly) if err != nil { return nil, err } @@ -396,7 +425,7 @@ func (s *EventService) SummaryCounts(ctx context.Context, userID uuid.UUID, filt return out, nil } -func (s *EventService) deadlineBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds) (*DeadlineBuckets, error) { +func (s *EventService) deadlineBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds, personalOnly bool) (*DeadlineBuckets, error) { conds := []string{visibilityPredicate("p")} args := map[string]any{ "user_id": userID, @@ -409,6 +438,9 @@ func (s *EventService) deadlineBuckets(ctx context.Context, userID uuid.UUID, pr conds = append(conds, projectDescendantPredicate("p")) args["project_id"] = *projectID } + if personalOnly { + conds = append(conds, `f.created_by = :user_id`) + } query := ` SELECT @@ -436,7 +468,7 @@ func (s *EventService) deadlineBuckets(ctx context.Context, userID uuid.UUID, pr return &c, nil } -func (s *EventService) appointmentBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds) (*AppointmentBuckets, error) { +func (s *EventService) appointmentBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds, personalOnly bool) (*AppointmentBuckets, error) { visibility := `( (t.project_id IS NULL AND t.created_by = :user_id) OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `) @@ -453,6 +485,12 @@ func (s *EventService) appointmentBuckets(ctx context.Context, userID uuid.UUID, conds = append(conds, projectDescendantPredicate("p")) args["project_id"] = *projectID } + if personalOnly { + // Narrow to rows the caller created. AND-joined with the visibility + // predicate above so an appointment a user created on a team they + // have since left still doesn't leak through. + conds = append(conds, `t.created_by = :user_id`) + } query := ` SELECT diff --git a/internal/services/event_service_test.go b/internal/services/event_service_test.go index 77d7b9e..f866929 100644 --- a/internal/services/event_service_test.go +++ b/internal/services/event_service_test.go @@ -154,41 +154,49 @@ func TestEventService_ListAndSummary_Live(t *testing.T) { ctx := context.Background() adminID := uuid.New() + otherID := uuid.New() // co-admin used to seed items NOT created by adminID projectID := uuid.New() - d1 := uuid.New() // overdue pending - d2 := uuid.New() // today pending - d3 := uuid.New() // next week pending - d4 := uuid.New() // completed - a1 := uuid.New() // today appointment - a2 := uuid.New() // later appointment + projectID2 := uuid.New() // second project so personal_only crosses projects + d1 := uuid.New() // overdue pending (admin) + d2 := uuid.New() // today pending (admin) + d3 := uuid.New() // next week pending (admin) + d4 := uuid.New() // completed (admin) + d5 := uuid.New() // pending in projectID created by otherID + d6 := uuid.New() // pending in projectID2 created by adminID + a1 := uuid.New() // today appointment (admin, projectID) + a2 := uuid.New() // later appointment (admin, projectID) + a3 := uuid.New() // soon appointment created by otherID (projectID) + a4 := uuid.New() // personal calendar appointment by adminID (no project) cleanup := func() { - pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id IN ($1, $2)`, a1, a2) - pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id IN ($1, $2, $3, $4)`, d1, d2, d3, d4) - pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, projectID) - pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID) - pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID) - pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, adminID) - pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, adminID) + pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id IN ($1, $2, $3, $4)`, a1, a2, a3, a4) + pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id IN ($1, $2, $3, $4, $5, $6)`, d1, d2, d3, d4, d5, d6) + pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, projectID, projectID2) + pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2)`, projectID, projectID2) + pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, projectID, projectID2) + pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id IN ($1, $2)`, adminID, otherID) + pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id IN ($1, $2)`, adminID, otherID) } cleanup() defer cleanup() if _, err := pool.ExecContext(ctx, - `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, - adminID, "vis-events@hlc.com"); err != nil { + `INSERT INTO auth.users (id, email) VALUES ($1, $2), ($3, $4)`, + adminID, "vis-events@hlc.com", otherID, "vis-events-other@hlc.com"); err != nil { t.Fatalf("seed auth.users: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.users (id, email, display_name, office, global_role, lang) - VALUES ($1, $2, 'Vis Events', 'munich', 'global_admin', 'de')`, - adminID, "vis-events@hlc.com"); err != nil { + VALUES ($1, $2, 'Vis Events', 'munich', 'global_admin', 'de'), + ($3, $4, 'Vis Events Other', 'munich', 'global_admin', 'de')`, + adminID, "vis-events@hlc.com", otherID, "vis-events-other@hlc.com"); err != nil { t.Fatalf("seed paliad.users: %v", err) } if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by) - VALUES ($1, 'project', $1::text, 'Vis Events Project', '2026/9994', 'active', $2)`, - projectID, adminID); err != nil { + VALUES ($1, 'project', $1::text, 'Vis Events Project', '2026/9994', 'active', $2), + ($3, 'project', $3::text, 'Vis Events Project 2', '2026/9995', 'active', $2)`, + projectID, adminID, projectID2); err != nil { t.Fatalf("seed paliad.projects: %v", err) } @@ -198,40 +206,54 @@ func TestEventService_ListAndSummary_Live(t *testing.T) { nextMon := today.AddDate(0, 0, ((7-int(today.Weekday()))%7)+1) // Mon-of-next-week farFuture := today.AddDate(0, 1, 0) - // Four deadlines spanning each bucket-shape: overdue, today, next_week, completed. + // Six deadlines: 4 spanning bucket shapes (admin/projectID), one pending + // in projectID created by otherID (so personal_only filters it out), one + // pending in projectID2 created by adminID (so personal_only spans + // projects). for _, d := range []struct { - id uuid.UUID - due time.Time - status string + id uuid.UUID + project uuid.UUID + due time.Time + status string + createdBy uuid.UUID }{ - {d1, yesterday, "pending"}, - {d2, today, "pending"}, - {d3, nextMon, "pending"}, - {d4, today, "completed"}, + {d1, projectID, yesterday, "pending", adminID}, + {d2, projectID, today, "pending", adminID}, + {d3, projectID, nextMon, "pending", adminID}, + {d4, projectID, today, "completed", adminID}, + {d5, projectID, nextMon, "pending", otherID}, + {d6, projectID2, nextMon, "pending", adminID}, } { if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, completed_at, created_by) VALUES ($1, $2, 'D', $3::date, 'manual', $4, CASE WHEN $4 = 'completed' THEN $5::timestamptz END, $6)`, - d.id, projectID, d.due.Format("2006-01-02"), d.status, now, adminID); err != nil { + d.id, d.project, d.due.Format("2006-01-02"), d.status, now, d.createdBy); err != nil { t.Fatalf("seed deadline %s: %v", d.id, err) } } - // Two appointments: one today, one far in the future. + // Four appointments: admin's today + far-future on projectID; otherID's + // soon appointment on projectID (foreign creator); admin's personal + // calendar entry with NULL project_id. + soon := today.Add(36 * time.Hour) for _, a := range []struct { - id uuid.UUID - start time.Time + id uuid.UUID + project *uuid.UUID + start time.Time + createdBy uuid.UUID }{ - {a1, today.Add(13 * time.Hour)}, - {a2, farFuture}, + {a1, &projectID, today.Add(13 * time.Hour), adminID}, + {a2, &projectID, farFuture, adminID}, + {a3, &projectID, soon, otherID}, + {a4, nil, today.Add(15 * time.Hour), adminID}, } { if _, err := pool.ExecContext(ctx, `INSERT INTO paliad.appointments (id, project_id, title, start_at, appointment_type, created_by) VALUES ($1, $2, 'A', $3, 'meeting', $4)`, - a.id, projectID, a.start, adminID); err != nil { + a.id, a.project, a.start, a.createdBy); err != nil { t.Fatalf("seed appointment %s: %v", a.id, err) } } @@ -432,4 +454,111 @@ func TestEventService_ListAndSummary_Live(t *testing.T) { t.Errorf("deadlines rail must be omitted on type=appointment (got %+v)", s.Deadlines) } }) + + // t-paliad-128: PersonalOnly narrows BOTH rails to rows the caller + // created. Spans projects (so a deadline created by adminID on + // projectID2 still shows up) and excludes rows otherID created on a + // project adminID can otherwise see. + seedDeadlines := map[uuid.UUID]bool{d1: true, d2: true, d3: true, d4: true, d5: true, d6: true} + seedAppointments := map[uuid.UUID]bool{a1: true, a2: true, a3: true, a4: true} + + t.Run("PersonalOnly type=deadline returns only caller-created deadlines across projects", func(t *testing.T) { + rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{ + Type: EventTypeDeadline, + PersonalOnly: true, + }) + if err != nil { + t.Fatalf("List deadline+personal: %v", err) + } + seen := map[uuid.UUID]bool{} + for _, r := range rows { + if !seedDeadlines[r.ID] { + continue + } + if r.Type != "deadline" { + t.Errorf("type=deadline returned %q row", r.Type) + } + if r.CreatedBy == nil || *r.CreatedBy != adminID { + t.Errorf("personal_only leaked row %s with created_by=%v", r.ID, r.CreatedBy) + } + seen[r.ID] = true + } + // admin's: d1 d2 d3 d4 d6 — all should appear. d5 (otherID) must not. + for _, want := range []uuid.UUID{d1, d2, d3, d4, d6} { + if !seen[want] { + t.Errorf("personal_only dropped admin-created deadline %s", want) + } + } + if seen[d5] { + t.Errorf("personal_only must exclude other-created deadline %s", d5) + } + }) + + t.Run("PersonalOnly type=appointment returns only caller-created appointments (with + without project)", func(t *testing.T) { + rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{ + Type: EventTypeAppointment, + PersonalOnly: true, + }) + if err != nil { + t.Fatalf("List appointment+personal: %v", err) + } + seen := map[uuid.UUID]bool{} + for _, r := range rows { + if !seedAppointments[r.ID] { + continue + } + if r.Type != "appointment" { + t.Errorf("type=appointment returned %q row", r.Type) + } + if r.CreatedBy == nil || *r.CreatedBy != adminID { + t.Errorf("personal_only leaked row %s with created_by=%v", r.ID, r.CreatedBy) + } + seen[r.ID] = true + } + // admin's: a1, a2 (project-attached), a4 (no project). a3 (otherID) must not. + for _, want := range []uuid.UUID{a1, a2, a4} { + if !seen[want] { + t.Errorf("personal_only dropped admin-created appointment %s", want) + } + } + if seen[a3] { + t.Errorf("personal_only must exclude other-created appointment %s", a3) + } + }) + + t.Run("PersonalOnly type=all returns the union of both", func(t *testing.T) { + rows, err := events.ListVisibleForUser(ctx, adminID, EventListFilter{ + Type: EventTypeAll, + PersonalOnly: true, + }) + if err != nil { + t.Fatalf("List all+personal: %v", err) + } + seenD := map[uuid.UUID]bool{} + seenA := map[uuid.UUID]bool{} + for _, r := range rows { + if r.CreatedBy == nil || *r.CreatedBy != adminID { + if seedDeadlines[r.ID] || seedAppointments[r.ID] { + t.Errorf("personal_only leaked row %s with created_by=%v", r.ID, r.CreatedBy) + } + continue + } + if r.Type == "deadline" && seedDeadlines[r.ID] { + seenD[r.ID] = true + } + if r.Type == "appointment" && seedAppointments[r.ID] { + seenA[r.ID] = true + } + } + for _, want := range []uuid.UUID{d1, d2, d3, d4, d6} { + if !seenD[want] { + t.Errorf("personal_only union dropped admin-created deadline %s", want) + } + } + for _, want := range []uuid.UUID{a1, a2, a4} { + if !seenA[want] { + t.Errorf("personal_only union dropped admin-created appointment %s", want) + } + } + }) }