Merge: t-paliad-128 — /events Nur persönliche redefined as created_by=me (deadlines + appointments)

This commit is contained in:
m
2026-05-04 19:53:02 +02:00
6 changed files with 286 additions and 84 deletions

View File

@@ -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<HTMLOptionElement>('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<HTMLButtonElement>('[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;

View File

@@ -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_typecomma-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
}

View File

@@ -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,

View File

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

View File

@@ -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

View File

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