Two related bugs on /projects/{id} Team tab → "Abgeleitet (Partner Unit)":
1. **All derived members labeled 'Attorney'.** Migration 055 added
partner_unit_members.unit_role with DEFAULT 'attorney' but never exposed
the column in the admin UI. So 100% of pum rows are 'attorney' and
Siemens AG's derive_unit_roles=['pa','senior_pa','attorney'] config
surfaces every member as 'attorney' even when they're really PAs.
2. **Multi-unit users collapsed to one source.** ListDerivedMembers used
ROW_NUMBER() OVER (PARTITION BY user_id) WHERE rn=1 — closest-attachment
wins, every other unit-membership dropped. Judith Molarinho Vaz +
Sabrina Franken belong to BOTH Lehment AND Plassmann; UI showed only one.
**Backend** (internal/services/derivation_service.go):
- DerivedMember.Memberships []DerivedMembership replaces scalar
UnitID/UnitName/UnitRole. DeriveGrantsAuthority becomes bool_or across
all source attachments (any granting → true).
- ListDerivedMembers SQL: jsonb_agg(DISTINCT jsonb_build_object(...)) +
bool_or(derive_grants_authority), GROUP BY user. One row per user, every
(unit, role) pair preserved. Memberships sorted by unit_name in Go (PG
doesn't allow ORDER BY inside DISTINCT-aggregated jsonb_agg).
- DerivedMembershipList implements sql.Scanner so the jsonb column maps
directly into the Go struct. Pinned by unit test.
**Frontend** (projects-detail.ts):
- DerivedMember interface mirrors the new shape. Herkunft renders every
(unit, role) source — single-unit users render as before
("über: **Lehment** [Sicht]"); multi-unit users render
"über: **Lehment** (Attorney), **Plassmann** (PA) [Sicht & 4-Augen]".
- Role column shows distinct unit_role values.
**Frontend** (admin-partner-units.ts):
- Member modal gains a per-row <select> with the 5 unit_role options. On
change, PATCH /api/partner-units/{id}/members/{user_id}/role (endpoint
already shipped in t-paliad-139 Phase 2). Disables during request,
rolls back the prior selection on failure.
- 2 new i18n keys (DE + EN): admin.partner_units.member.role,
admin.partner_units.feedback.role_updated.
- New CSS for .partner-unit-member-item flex layout + .pu-role-select.
**Out of scope** (per design): semantics of derive_unit_roles, new
unit_role values beyond the 5-row CHECK, the bigger profession-vs-project-
role redesign (#6).
**Verification**:
- Live SQL dry-run on Siemens AG (61e3fb9e-29fb-44aa-867e-a89469e2cacb)
returns Judith + Sabrina each with [{Lehment,attorney},{Plassmann,attorney}]
and derive_grants_authority=true (Plassmann grants authority).
- DerivedMembershipList.Scan unit-tested for nil / single / multi /
unsupported-type cases.
- Go build + tests pass; frontend build clean (1608 i18n keys).
After merge, m can verify on prod: /admin/partner-units → Plassmann →
set Judith to 'pa' → reload Siemens AG Team tab → Judith shows as 'PA'
with Herkunft "über: **Lehment** (Attorney), **Plassmann** (PA)".
72 lines
2.0 KiB
Go
72 lines
2.0 KiB
Go
package services
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// TestDerivedMembershipListScan covers the sql.Scanner over a Postgres
|
|
// jsonb column — the wire format that ListDerivedMembers' jsonb_agg
|
|
// returns. Pinned because if a future migration changes the JSON shape
|
|
// (e.g. drops a key), the rendered Herkunft column on /projects/{id}
|
|
// silently breaks (t-paliad-143).
|
|
func TestDerivedMembershipListScan(t *testing.T) {
|
|
unitA := uuid.New()
|
|
unitB := uuid.New()
|
|
|
|
cases := []struct {
|
|
name string
|
|
src any
|
|
want []DerivedMembership
|
|
}{
|
|
{
|
|
name: "nil",
|
|
src: nil,
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "single membership as bytes",
|
|
src: []byte(`[{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"}]`),
|
|
want: []DerivedMembership{{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"}},
|
|
},
|
|
{
|
|
name: "two memberships as string",
|
|
src: `[
|
|
{"unit_id":"` + unitA.String() + `","unit_name":"Lehment","unit_role":"attorney"},
|
|
{"unit_id":"` + unitB.String() + `","unit_name":"Plassmann","unit_role":"pa"}
|
|
]`,
|
|
want: []DerivedMembership{
|
|
{UnitID: unitA, UnitName: "Lehment", UnitRole: "attorney"},
|
|
{UnitID: unitB, UnitName: "Plassmann", UnitRole: "pa"},
|
|
},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var got DerivedMembershipList
|
|
if err := got.Scan(tc.src); err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
if len(got) != len(tc.want) {
|
|
t.Fatalf("len: got %d want %d", len(got), len(tc.want))
|
|
}
|
|
for i := range got {
|
|
if got[i] != tc.want[i] {
|
|
t.Errorf("row %d: got %+v want %+v", i, got[i], tc.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDerivedMembershipListScanRejectsUnknown ensures we don't silently
|
|
// accept random column types and produce an empty list (which would mask
|
|
// a schema regression).
|
|
func TestDerivedMembershipListScanRejectsUnknown(t *testing.T) {
|
|
var l DerivedMembershipList
|
|
if err := l.Scan(123); err == nil {
|
|
t.Fatal("expected error scanning int into DerivedMembershipList, got nil")
|
|
}
|
|
}
|