Migration 030 adds paliad.event_types and paliad.deadline_event_types
junction. ~43 firm-wide seeds biased toward submissions (25 UPC
submissions + 8 UPC decisions/orders/hearings + 5 EPO + 4 DPMA/DE + 1
cross-jurisdiction). UPC-seeded rows carry a loose trigger_event_id
column (no FK constraint per Q2: event_types leads, trigger_events
follows). RLS policies are defense-in-depth — primary enforcement is
in the Go service layer. Per Q6, any authenticated user can create
firm-wide types; admins moderate via the soft-delete archive lever.
EventTypeService: List (firm-wide ∪ own-private), GetByID, Create
(slug auto-derived, supports diacritics → ASCII), Update (author OR
admin-on-firm-wide), SuggestSimilar (powers the duplicate-warning in
the add modal), AttachToDeadlineTx + ValidateForUser + ListForDeadlines
for the junction.
DeadlineService gains an EventTypeService dependency and now:
- accepts event_type_ids on Create / Update / CreateBulk
- attaches them in the same transaction as the deadline insert
- hydrates EventTypeIDs on every Get / List / ListForProject
- supports the multi-select Typ filter via ListFilter.EventTypeIDs +
IncludeUntyped (UNION semantics within types, AND-intersected with
Status/Project)
AgendaService gets the same Typ filter on its deadline side;
appointments are unaffected.
API:
- GET /api/event-types?category=&jurisdiction=
- GET /api/event-types/suggest?q=
- POST /api/event-types
- PATCH /api/event-types/{id} (set archive=true to hide)
- GET /api/deadlines?event_type=<uuid>,<uuid>,none
- GET /api/agenda?event_type=<uuid>,<uuid>,none
- POST/PATCH /api/deadlines accept event_type_ids: [uuid]
go build / go vet / go test ./... clean.
Frontend (picker + custom-add modal + multi-select filter) follows in
PR-2. Admin moderation panel deferred to t-paliad-089 follow-up.
326 lines
12 KiB
Go
326 lines
12 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
_ "github.com/lib/pq"
|
|
|
|
"mgit.msbls.de/m/patholo/internal/db"
|
|
)
|
|
|
|
// TestVisibilityPredicate_GlobalAdminWithoutTeam is the regression for
|
|
// t-paliad-058. A global_admin without any project_teams row must still be
|
|
// able to read every Project — matching paliad.can_see_project's RLS
|
|
// behaviour. Pre-fix, visibilityPredicatePositional compared the bound role
|
|
// to the literal 'admin', so callers passing the (correct) 'global_admin'
|
|
// value silently fell through to the team-membership branch and got 404.
|
|
//
|
|
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB tests.
|
|
func TestVisibilityPredicate_GlobalAdminWithoutTeam(t *testing.T) {
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
|
}
|
|
if err := db.ApplyMigrations(url); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
pool, err := sqlx.Connect("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
adminID := uuid.New()
|
|
standardID := uuid.New()
|
|
projectID := uuid.New()
|
|
|
|
cleanup := func() {
|
|
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 IN ($1, $2)`, adminID, standardID)
|
|
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id IN ($1, $2)`, adminID, standardID)
|
|
}
|
|
cleanup()
|
|
defer cleanup()
|
|
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO auth.users (id, email) VALUES ($1, $2), ($3, $4)`,
|
|
adminID, "vis-admin@hlc.com", standardID, "vis-standard@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 Admin', 'munich', 'global_admin', 'de'),
|
|
($3, $4, 'Vis Standard', 'munich', 'standard', 'de')`,
|
|
adminID, "vis-admin@hlc.com", standardID, "vis-standard@hlc.com"); err != nil {
|
|
t.Fatalf("seed paliad.users: %v", err)
|
|
}
|
|
|
|
// Project created by adminID. NOTE: ProjectService.Create normally
|
|
// auto-adds the creator to project_teams (so they'd see the project via
|
|
// team membership too) — for this regression we INSERT the row directly
|
|
// and leave project_teams empty. That isolates the global_admin shortcut.
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by)
|
|
VALUES ($1, 'project', $1::text, 'Visibility Test Project', '2026/9997', 'active', $2)`,
|
|
projectID, adminID); err != nil {
|
|
t.Fatalf("seed paliad.projects: %v", err)
|
|
}
|
|
|
|
users := NewUserService(pool)
|
|
projects := NewProjectService(pool, users)
|
|
|
|
// global_admin without team membership: must see the project.
|
|
if _, err := projects.GetByID(ctx, adminID, projectID); err != nil {
|
|
t.Fatalf("GetByID for global_admin without team: %v (want nil — global_admin sees all)", err)
|
|
}
|
|
tree, err := projects.BuildTree(ctx, adminID)
|
|
if err != nil {
|
|
t.Fatalf("BuildTree for global_admin: %v", err)
|
|
}
|
|
if !treeContains(tree, projectID) {
|
|
t.Errorf("BuildTree omitted project %s for global_admin", projectID)
|
|
}
|
|
|
|
// standard user without team membership: must NOT see the project.
|
|
if _, err := projects.GetByID(ctx, standardID, projectID); !errors.Is(err, ErrNotVisible) {
|
|
t.Fatalf("GetByID for standard without team: err=%v, want ErrNotVisible", err)
|
|
}
|
|
tree, err = projects.BuildTree(ctx, standardID)
|
|
if err != nil {
|
|
t.Fatalf("BuildTree for standard: %v", err)
|
|
}
|
|
if treeContains(tree, projectID) {
|
|
t.Errorf("BuildTree leaked project %s to standard user without team", projectID)
|
|
}
|
|
}
|
|
|
|
func treeContains(nodes []*ProjectTreeNode, id uuid.UUID) bool {
|
|
for _, n := range nodes {
|
|
if n.ID == id {
|
|
return true
|
|
}
|
|
if treeContains(n.Children, id) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestVisibilityPredicate_DashboardAgendaForGlobalAdmin is the regression for
|
|
// t-paliad-076. After consolidating the inlined predicates in dashboard and
|
|
// agenda services, a global_admin without any project_teams row must still
|
|
// see Project-scoped rows on:
|
|
// - DashboardService.Get (summary counts, upcoming deadlines, upcoming
|
|
// appointments, recent activity)
|
|
// - AgendaService.List (deadlines + appointments)
|
|
//
|
|
// The standard user with no membership must see none of those rows.
|
|
//
|
|
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB tests.
|
|
func TestVisibilityPredicate_DashboardAgendaForGlobalAdmin(t *testing.T) {
|
|
url := os.Getenv("TEST_DATABASE_URL")
|
|
if url == "" {
|
|
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
|
}
|
|
if err := db.ApplyMigrations(url); err != nil {
|
|
t.Fatalf("apply migrations: %v", err)
|
|
}
|
|
pool, err := sqlx.Connect("postgres", url)
|
|
if err != nil {
|
|
t.Fatalf("connect: %v", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
ctx := context.Background()
|
|
|
|
adminID := uuid.New()
|
|
standardID := uuid.New()
|
|
projectID := uuid.New()
|
|
deadlineID := uuid.New()
|
|
appointmentID := uuid.New()
|
|
eventID := uuid.New()
|
|
|
|
cleanup := func() {
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id = $1`, projectID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id = $1`, appointmentID)
|
|
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID)
|
|
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 IN ($1, $2)`, adminID, standardID)
|
|
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id IN ($1, $2)`, adminID, standardID)
|
|
}
|
|
cleanup()
|
|
defer cleanup()
|
|
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO auth.users (id, email) VALUES ($1, $2), ($3, $4)`,
|
|
adminID, "vis-da-admin@hlc.com", standardID, "vis-da-standard@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 DA Admin', 'munich', 'global_admin', 'de'),
|
|
($3, $4, 'Vis DA Standard', 'munich', 'standard', 'de')`,
|
|
adminID, "vis-da-admin@hlc.com", standardID, "vis-da-standard@hlc.com"); err != nil {
|
|
t.Fatalf("seed paliad.users: %v", err)
|
|
}
|
|
// Project owned by adminID. project_teams intentionally left empty so the
|
|
// only path to visibility is the global_admin branch of the predicate.
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by)
|
|
VALUES ($1, 'project', $1::text, 'Visibility DA Project', '2026/9996', 'active', $2)`,
|
|
projectID, adminID); err != nil {
|
|
t.Fatalf("seed paliad.projects: %v", err)
|
|
}
|
|
|
|
// Seed one pending deadline within the dashboard's 7-day window so it
|
|
// shows up in both summary counts AND the upcoming list.
|
|
dueDate := time.Now().UTC().AddDate(0, 0, 2).Format("2006-01-02")
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, created_by)
|
|
VALUES ($1, $2, 'Vis DA Deadline', $3::date, 'manual', 'pending', $4)`,
|
|
deadlineID, projectID, dueDate, adminID); err != nil {
|
|
t.Fatalf("seed paliad.deadlines: %v", err)
|
|
}
|
|
// Seed one appointment within the dashboard's 7-day window.
|
|
startAt := time.Now().UTC().Add(48 * time.Hour)
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.appointments
|
|
(id, project_id, title, start_at, appointment_type, created_by)
|
|
VALUES ($1, $2, 'Vis DA Appointment', $3, 'meeting', $4)`,
|
|
appointmentID, projectID, startAt, adminID); err != nil {
|
|
t.Fatalf("seed paliad.appointments: %v", err)
|
|
}
|
|
// Seed one project event so recent activity has a row to surface.
|
|
if _, err := pool.ExecContext(ctx,
|
|
`INSERT INTO paliad.project_events
|
|
(id, project_id, event_type, title, description, event_date,
|
|
created_by, metadata, created_at, updated_at)
|
|
VALUES ($1, $2, 'project_created', 'Created', NULL, $3, $4,
|
|
'{}'::jsonb, $3, $3)`,
|
|
eventID, projectID, time.Now().UTC(), adminID); err != nil {
|
|
t.Fatalf("seed paliad.project_events: %v", err)
|
|
}
|
|
|
|
users := NewUserService(pool)
|
|
dashboard := NewDashboardService(pool, users)
|
|
agenda := NewAgendaService(pool, users, nil)
|
|
|
|
t.Run("global_admin sees dashboard rows without team membership", func(t *testing.T) {
|
|
data, err := dashboard.Get(ctx, adminID)
|
|
if err != nil {
|
|
t.Fatalf("dashboard.Get: %v", err)
|
|
}
|
|
if data.MatterSummary.Total < 1 {
|
|
t.Errorf("MatterSummary.Total = %d, want >= 1 (global_admin must see the seeded project)", data.MatterSummary.Total)
|
|
}
|
|
if data.DeadlineSummary.ThisWeek+data.DeadlineSummary.Upcoming+data.DeadlineSummary.Overdue < 1 {
|
|
t.Errorf("DeadlineSummary has 0 pending; want >= 1 (global_admin must see the seeded deadline)")
|
|
}
|
|
if !containsDeadline(data.UpcomingDeadlines, deadlineID) {
|
|
t.Errorf("UpcomingDeadlines missing %s for global_admin", deadlineID)
|
|
}
|
|
if !containsAppointment(data.UpcomingAppointments, appointmentID) {
|
|
t.Errorf("UpcomingAppointments missing %s for global_admin", appointmentID)
|
|
}
|
|
if !containsActivityProject(data.RecentActivity, projectID) {
|
|
t.Errorf("RecentActivity missing project %s for global_admin", projectID)
|
|
}
|
|
})
|
|
|
|
t.Run("standard user sees nothing without team membership", func(t *testing.T) {
|
|
data, err := dashboard.Get(ctx, standardID)
|
|
if err != nil {
|
|
t.Fatalf("dashboard.Get standard: %v", err)
|
|
}
|
|
if containsDeadline(data.UpcomingDeadlines, deadlineID) {
|
|
t.Errorf("UpcomingDeadlines leaked %s to standard user", deadlineID)
|
|
}
|
|
if containsAppointment(data.UpcomingAppointments, appointmentID) {
|
|
t.Errorf("UpcomingAppointments leaked %s to standard user", appointmentID)
|
|
}
|
|
if containsActivityProject(data.RecentActivity, projectID) {
|
|
t.Errorf("RecentActivity leaked project %s to standard user", projectID)
|
|
}
|
|
})
|
|
|
|
t.Run("global_admin sees agenda rows without team membership", func(t *testing.T) {
|
|
from := time.Now().UTC().AddDate(0, 0, -1)
|
|
to := time.Now().UTC().AddDate(0, 0, 14)
|
|
items, err := agenda.List(ctx, adminID, AgendaFilter{
|
|
From: from, To: to, IncludeDeadlines: true, IncludeAppointments: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("agenda.List: %v", err)
|
|
}
|
|
if !containsAgendaID(items, deadlineID) {
|
|
t.Errorf("agenda missing deadline %s for global_admin", deadlineID)
|
|
}
|
|
if !containsAgendaID(items, appointmentID) {
|
|
t.Errorf("agenda missing appointment %s for global_admin", appointmentID)
|
|
}
|
|
})
|
|
|
|
t.Run("standard user sees no agenda rows without team membership", func(t *testing.T) {
|
|
from := time.Now().UTC().AddDate(0, 0, -1)
|
|
to := time.Now().UTC().AddDate(0, 0, 14)
|
|
items, err := agenda.List(ctx, standardID, AgendaFilter{
|
|
From: from, To: to, IncludeDeadlines: true, IncludeAppointments: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("agenda.List standard: %v", err)
|
|
}
|
|
if containsAgendaID(items, deadlineID) {
|
|
t.Errorf("agenda leaked deadline %s to standard user", deadlineID)
|
|
}
|
|
if containsAgendaID(items, appointmentID) {
|
|
t.Errorf("agenda leaked appointment %s to standard user", appointmentID)
|
|
}
|
|
})
|
|
}
|
|
|
|
func containsDeadline(rows []UpcomingDeadline, id uuid.UUID) bool {
|
|
for _, r := range rows {
|
|
if r.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsAppointment(rows []UpcomingAppointment, id uuid.UUID) bool {
|
|
for _, r := range rows {
|
|
if r.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsActivityProject(rows []ActivityEntry, projectID uuid.UUID) bool {
|
|
for _, r := range rows {
|
|
if r.ProjectID == projectID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func containsAgendaID(items []AgendaItem, id uuid.UUID) bool {
|
|
for _, it := range items {
|
|
if it.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|