Merge: t-paliad-143 — derived members per-unit role + multi-unit Herkunft (admin UI dropdown + array_agg in DerivationService)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
interface PartnerUnit {
|
||||
@@ -16,8 +16,11 @@ interface Member {
|
||||
display_name: string;
|
||||
office: string;
|
||||
job_title: string | null;
|
||||
unit_role: string;
|
||||
}
|
||||
|
||||
const UNIT_ROLES = ["lead", "attorney", "senior_pa", "pa", "paralegal"] as const;
|
||||
|
||||
interface PartnerUnitWithMembers extends PartnerUnit {
|
||||
lead_display_name?: string;
|
||||
lead_email?: string;
|
||||
@@ -284,16 +287,54 @@ function renderMemberList(): void {
|
||||
return;
|
||||
}
|
||||
list.innerHTML = u.members
|
||||
.map(
|
||||
(m) => `<li class="partner-unit-member-item">
|
||||
.map((m) => {
|
||||
const roleOptions = UNIT_ROLES.map((r) => {
|
||||
const label = tDyn(`unit_role.${r}`) || r;
|
||||
const sel = m.unit_role === r ? " selected" : "";
|
||||
return `<option value="${esc(r)}"${sel}>${esc(label)}</option>`;
|
||||
}).join("");
|
||||
return `<li class="partner-unit-member-item">
|
||||
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
|
||||
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
|
||||
</li>`,
|
||||
)
|
||||
<span class="partner-unit-member-actions">
|
||||
<select class="pu-role-select" data-user="${esc(m.user_id)}" aria-label="${escAttr(tDyn("admin.partner_units.member.role") || "Rolle")}">${roleOptions}</select>
|
||||
<button type="button" class="btn-ghost btn-small pu-remove-btn" data-user="${esc(m.user_id)}">${esc(t("admin.partner_units.member.remove") || "Entfernen")}</button>
|
||||
</span>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
|
||||
b.addEventListener("click", () => removeMember(b.dataset.user!)),
|
||||
);
|
||||
list.querySelectorAll<HTMLSelectElement>(".pu-role-select").forEach((s) =>
|
||||
s.addEventListener("change", () => setMemberRole(s.dataset.user!, s.value, s)),
|
||||
);
|
||||
}
|
||||
|
||||
async function setMemberRole(userID: string, role: string, sel: HTMLSelectElement): Promise<void> {
|
||||
if (!activeUnitID) return;
|
||||
// Snapshot the prior selection so we can roll back on failure.
|
||||
const u = units.find((x) => x.id === activeUnitID);
|
||||
const prior = u?.members.find((m) => m.user_id === userID)?.unit_role;
|
||||
sel.disabled = true;
|
||||
const resp = await fetch(
|
||||
`/api/partner-units/${activeUnitID}/members/${userID}/role`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ unit_role: role }),
|
||||
},
|
||||
);
|
||||
sel.disabled = false;
|
||||
if (!resp.ok) {
|
||||
if (prior !== undefined) sel.value = prior;
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || "Rolle konnte nicht gespeichert werden.", true);
|
||||
return;
|
||||
}
|
||||
await loadUnits();
|
||||
renderMemberList();
|
||||
render();
|
||||
showFeedback(tDyn("admin.partner_units.feedback.role_updated") || "Rolle aktualisiert.", false);
|
||||
}
|
||||
|
||||
function wireSuggestions(): void {
|
||||
|
||||
@@ -1544,6 +1544,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.partner_units.feedback.created": "Angelegt.",
|
||||
"admin.partner_units.feedback.updated": "Aktualisiert.",
|
||||
"admin.partner_units.feedback.deleted": "Gelöscht.",
|
||||
"admin.partner_units.feedback.role_updated": "Rolle aktualisiert.",
|
||||
"admin.partner_units.member.heading": "Mitglieder verwalten",
|
||||
"admin.partner_units.member.empty": "Noch keine Mitglieder.",
|
||||
"admin.partner_units.member.add": "Mitglied hinzufügen",
|
||||
@@ -1551,6 +1552,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.partner_units.member.remove": "Entfernen",
|
||||
"admin.partner_units.member.confirm_remove": "Mitglied entfernen?",
|
||||
"admin.partner_units.member.placeholder": "Name oder E-Mail",
|
||||
"admin.partner_units.member.role": "Rolle",
|
||||
"admin.audit.loading": "Lade…",
|
||||
"admin.audit.empty": "Keine Ereignisse für die gewählten Filter.",
|
||||
"admin.audit.loadmore": "Weitere laden",
|
||||
@@ -3255,6 +3257,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.partner_units.feedback.created": "Created.",
|
||||
"admin.partner_units.feedback.updated": "Updated.",
|
||||
"admin.partner_units.feedback.deleted": "Deleted.",
|
||||
"admin.partner_units.feedback.role_updated": "Role updated.",
|
||||
"admin.partner_units.member.heading": "Manage members",
|
||||
"admin.partner_units.member.empty": "No members yet.",
|
||||
"admin.partner_units.member.add": "Add member",
|
||||
@@ -3262,6 +3265,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"admin.partner_units.member.remove": "Remove",
|
||||
"admin.partner_units.member.confirm_remove": "Remove member?",
|
||||
"admin.partner_units.member.placeholder": "Name or email",
|
||||
"admin.partner_units.member.role": "Role",
|
||||
"admin.audit.loading": "Loading…",
|
||||
"admin.audit.empty": "No events match the selected filters.",
|
||||
"admin.audit.loadmore": "Load more",
|
||||
|
||||
@@ -47,14 +47,21 @@ interface ProjectTeamMember {
|
||||
}
|
||||
|
||||
// t-paliad-139 — derived team member from a partner-unit attachment.
|
||||
// One DerivedMember per user; users in multiple attached units carry one
|
||||
// DerivedMembership per (unit, role) pair so the Herkunft column can list
|
||||
// every source (t-paliad-143).
|
||||
interface DerivedMembership {
|
||||
unit_id: string;
|
||||
unit_name: string;
|
||||
unit_role: string;
|
||||
}
|
||||
|
||||
interface DerivedMember {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
user_display_name: string;
|
||||
user_office: string;
|
||||
unit_role: string;
|
||||
unit_id: string;
|
||||
unit_name: string;
|
||||
memberships: DerivedMembership[];
|
||||
derive_grants_authority: boolean;
|
||||
}
|
||||
|
||||
@@ -1736,7 +1743,25 @@ function renderDerivedMembers() {
|
||||
section.style.display = "";
|
||||
body.innerHTML = derivedMembers
|
||||
.map((m) => {
|
||||
const roleLabel = tDyn(`unit_role.${m.unit_role}`) || m.unit_role;
|
||||
const memberships = m.memberships || [];
|
||||
// Role column shows distinct unit_role values (usually one — only
|
||||
// diverges if the user has different roles in different units).
|
||||
const distinctRoles = Array.from(new Set(memberships.map((x) => x.unit_role)));
|
||||
const roleLabel = distinctRoles
|
||||
.map((r) => tDyn(`unit_role.${r}`) || r)
|
||||
.join(", ");
|
||||
// Herkunft column lists every (unit, role) pair so multi-unit users
|
||||
// surface all their sources, not just the closest one (t-paliad-143).
|
||||
// Multi-unit: bold each unit name and append the role in parentheses.
|
||||
// Single-unit: bold the one unit name (matches the legacy rendering).
|
||||
const sourceLabel = memberships
|
||||
.map((x) => {
|
||||
const name = `<strong>${esc(x.unit_name)}</strong>`;
|
||||
if (memberships.length === 1) return name;
|
||||
const role = esc(tDyn(`unit_role.${x.unit_role}`) || x.unit_role);
|
||||
return `${name} (${role})`;
|
||||
})
|
||||
.join(", ");
|
||||
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||
const authBadge = m.derive_grants_authority
|
||||
? `<span class="derived-badge derived-badge--authority" title="${escAttr(t("projects.team.derived.authority.hint") || "Authority granted")}">${esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")}</span>`
|
||||
@@ -1745,7 +1770,7 @@ function renderDerivedMembers() {
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
|
||||
<td>${esc(t("projects.team.derived.from") || "über")}: <strong>${esc(m.unit_name)}</strong> ${authBadge}</td>
|
||||
<td>${esc(t("projects.team.derived.from") || "über")}: ${sourceLabel} ${authBadge}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
@@ -167,6 +167,7 @@ export type I18nKey =
|
||||
| "admin.partner_units.error.user_required"
|
||||
| "admin.partner_units.feedback.created"
|
||||
| "admin.partner_units.feedback.deleted"
|
||||
| "admin.partner_units.feedback.role_updated"
|
||||
| "admin.partner_units.feedback.updated"
|
||||
| "admin.partner_units.heading"
|
||||
| "admin.partner_units.loading"
|
||||
@@ -177,6 +178,7 @@ export type I18nKey =
|
||||
| "admin.partner_units.member.heading"
|
||||
| "admin.partner_units.member.placeholder"
|
||||
| "admin.partner_units.member.remove"
|
||||
| "admin.partner_units.member.role"
|
||||
| "admin.partner_units.new"
|
||||
| "admin.partner_units.new.heading"
|
||||
| "admin.partner_units.subtitle"
|
||||
|
||||
@@ -8911,6 +8911,44 @@ dialog.quick-add-sheet::backdrop {
|
||||
background: var(--color-bg-lime-tint);
|
||||
}
|
||||
|
||||
/* /admin/partner-units member modal — list of (display_name, role-select,
|
||||
remove) rows. The role-select is wired to PATCH …/members/{user}/role
|
||||
(t-paliad-143). */
|
||||
.partner-unit-member-list {
|
||||
list-style: none;
|
||||
margin: 0 0 1rem 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.partner-unit-member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.partner-unit-member-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.partner-unit-member-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pu-role-select {
|
||||
padding: 0.25rem 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-team-input {
|
||||
width: 100%;
|
||||
padding: 0.3rem 0.45rem;
|
||||
|
||||
71
internal/services/derivation_membership_scan_test.go
Normal file
71
internal/services/derivation_membership_scan_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
@@ -49,18 +50,49 @@ type AttachedUnit struct {
|
||||
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
|
||||
}
|
||||
|
||||
// DerivedMember is one user who currently derives onto a project via an
|
||||
// attached partner unit. Used by the Team tab "Abgeleitet (Partner Unit)"
|
||||
// subsection.
|
||||
// DerivedMembership is one (unit, role) pair through which a user currently
|
||||
// derives onto a project. A multi-unit user has one DerivedMembership per
|
||||
// unit they belong to that's attached to the project (or one of its
|
||||
// ancestors) AND whose unit_role is in the attachment's derive_unit_roles.
|
||||
type DerivedMembership struct {
|
||||
UnitID uuid.UUID `json:"unit_id"`
|
||||
UnitName string `json:"unit_name"`
|
||||
UnitRole string `json:"unit_role"`
|
||||
}
|
||||
|
||||
// DerivedMembershipList is a []DerivedMembership that scans from a Postgres
|
||||
// jsonb column (the array_agg/jsonb_agg payload in ListDerivedMembers).
|
||||
type DerivedMembershipList []DerivedMembership
|
||||
|
||||
// Scan implements sql.Scanner over a jsonb array.
|
||||
func (l *DerivedMembershipList) Scan(src any) error {
|
||||
if src == nil {
|
||||
*l = nil
|
||||
return nil
|
||||
}
|
||||
var raw []byte
|
||||
switch v := src.(type) {
|
||||
case []byte:
|
||||
raw = v
|
||||
case string:
|
||||
raw = []byte(v)
|
||||
default:
|
||||
return fmt.Errorf("DerivedMembershipList.Scan: unsupported type %T", src)
|
||||
}
|
||||
return json.Unmarshal(raw, (*[]DerivedMembership)(l))
|
||||
}
|
||||
|
||||
// DerivedMember is one user who currently derives onto a project. The user
|
||||
// may derive via multiple units (e.g. a PA who works with two partners);
|
||||
// each is one entry in Memberships. DeriveGrantsAuthority is true if any
|
||||
// of the source attachments have authority enabled.
|
||||
type DerivedMember struct {
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Email string `db:"email" json:"user_email"`
|
||||
DisplayName string `db:"display_name" json:"user_display_name"`
|
||||
Office string `db:"office" json:"user_office"`
|
||||
UnitRole string `db:"unit_role" json:"unit_role"`
|
||||
UnitID uuid.UUID `db:"unit_id" json:"unit_id"`
|
||||
UnitName string `db:"unit_name" json:"unit_name"`
|
||||
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Email string `db:"email" json:"user_email"`
|
||||
DisplayName string `db:"display_name" json:"user_display_name"`
|
||||
Office string `db:"office" json:"user_office"`
|
||||
Memberships DerivedMembershipList `db:"memberships" json:"memberships"`
|
||||
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
|
||||
}
|
||||
|
||||
// AttachUnitOptions controls how a unit is attached. Empty values use the
|
||||
@@ -190,9 +222,12 @@ func (s *DerivationService) ListAttachedUnits(ctx context.Context, callerID, pro
|
||||
// down to descendants — derivation honours the same direction as
|
||||
// can_see_project.
|
||||
//
|
||||
// Dedupe: if the same user derives via multiple (unit, ancestor) pairs,
|
||||
// the closest-attachment row wins (smallest path-distance). One row per
|
||||
// user.
|
||||
// One row per user. Multi-unit users (e.g. a PA working across two partner
|
||||
// units, both of which are attached to the project's path) carry every
|
||||
// (unit, role) pair in Memberships so the Herkunft column can list them
|
||||
// all (t-paliad-143). DeriveGrantsAuthority is bool_or across the
|
||||
// underlying attachments — a user with at least one authority-granting
|
||||
// derivation source qualifies as authority-bearing for approval purposes.
|
||||
func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]DerivedMember, error) {
|
||||
project, err := s.projects.GetByID(ctx, callerID, projectID)
|
||||
if err != nil {
|
||||
@@ -209,44 +244,40 @@ func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, pr
|
||||
SELECT ppu.project_id AS attach_project_id,
|
||||
ppu.partner_unit_id,
|
||||
ppu.derive_unit_roles,
|
||||
ppu.derive_grants_authority,
|
||||
array_position($1::uuid[], ppu.project_id) AS depth_rank
|
||||
ppu.derive_grants_authority
|
||||
FROM paliad.project_partner_units ppu
|
||||
WHERE ppu.project_id = ANY($1::uuid[])
|
||||
),
|
||||
candidate AS (
|
||||
SELECT pum.user_id, pum.unit_role,
|
||||
a.partner_unit_id, a.derive_grants_authority, a.depth_rank
|
||||
FROM attached a
|
||||
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = a.partner_unit_id
|
||||
WHERE pum.unit_role = ANY(a.derive_unit_roles)
|
||||
),
|
||||
ranked AS (
|
||||
SELECT c.*,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY c.user_id
|
||||
-- Closest attachment wins: highest depth_rank in path
|
||||
-- (path is root→…→self, array_position returns 1-based,
|
||||
-- so larger = nearer to self).
|
||||
ORDER BY c.depth_rank DESC NULLS LAST
|
||||
) AS rn
|
||||
FROM candidate c
|
||||
)
|
||||
SELECT r.user_id,
|
||||
SELECT pum.user_id,
|
||||
u.email, u.display_name, u.office,
|
||||
r.unit_role,
|
||||
r.partner_unit_id AS unit_id,
|
||||
pu.name AS unit_name,
|
||||
r.derive_grants_authority
|
||||
FROM ranked r
|
||||
JOIN paliad.users u ON u.id = r.user_id
|
||||
JOIN paliad.partner_units pu ON pu.id = r.partner_unit_id
|
||||
WHERE r.rn = 1
|
||||
jsonb_agg(DISTINCT jsonb_build_object(
|
||||
'unit_id', a.partner_unit_id,
|
||||
'unit_name', pu.name,
|
||||
'unit_role', pum.unit_role
|
||||
)) AS memberships,
|
||||
bool_or(a.derive_grants_authority) AS derive_grants_authority
|
||||
FROM attached a
|
||||
JOIN paliad.partner_unit_members pum ON pum.partner_unit_id = a.partner_unit_id
|
||||
JOIN paliad.users u ON u.id = pum.user_id
|
||||
JOIN paliad.partner_units pu ON pu.id = a.partner_unit_id
|
||||
WHERE pum.unit_role = ANY(a.derive_unit_roles)
|
||||
GROUP BY pum.user_id, u.email, u.display_name, u.office
|
||||
ORDER BY u.display_name`,
|
||||
pq.StringArray(ancestorIDs))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list derived members: %w", err)
|
||||
}
|
||||
// jsonb_agg(DISTINCT …) doesn't support ORDER BY in the same call.
|
||||
// Sort each member's memberships by unit_name in Go so the Herkunft
|
||||
// column renders deterministically.
|
||||
for i := range rows {
|
||||
ms := rows[i].Memberships
|
||||
for j := 1; j < len(ms); j++ {
|
||||
for k := j; k > 0 && ms[k-1].UnitName > ms[k].UnitName; k-- {
|
||||
ms[k-1], ms[k] = ms[k], ms[k-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user