Merge: t-paliad-128 — /events Nur persönliche redefined as created_by=me (deadlines + appointments)
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user