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";
|
import { initSidebar } from "./sidebar";
|
||||||
|
|
||||||
interface PartnerUnit {
|
interface PartnerUnit {
|
||||||
@@ -16,8 +16,11 @@ interface Member {
|
|||||||
display_name: string;
|
display_name: string;
|
||||||
office: string;
|
office: string;
|
||||||
job_title: string | null;
|
job_title: string | null;
|
||||||
|
unit_role: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const UNIT_ROLES = ["lead", "attorney", "senior_pa", "pa", "paralegal"] as const;
|
||||||
|
|
||||||
interface PartnerUnitWithMembers extends PartnerUnit {
|
interface PartnerUnitWithMembers extends PartnerUnit {
|
||||||
lead_display_name?: string;
|
lead_display_name?: string;
|
||||||
lead_email?: string;
|
lead_email?: string;
|
||||||
@@ -284,16 +287,54 @@ function renderMemberList(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = u.members
|
list.innerHTML = u.members
|
||||||
.map(
|
.map((m) => {
|
||||||
(m) => `<li class="partner-unit-member-item">
|
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>
|
<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>
|
<span class="partner-unit-member-actions">
|
||||||
</li>`,
|
<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("");
|
.join("");
|
||||||
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
|
list.querySelectorAll<HTMLButtonElement>(".pu-remove-btn").forEach((b) =>
|
||||||
b.addEventListener("click", () => removeMember(b.dataset.user!)),
|
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 {
|
function wireSuggestions(): void {
|
||||||
|
|||||||
@@ -1544,6 +1544,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
|||||||
"admin.partner_units.feedback.created": "Angelegt.",
|
"admin.partner_units.feedback.created": "Angelegt.",
|
||||||
"admin.partner_units.feedback.updated": "Aktualisiert.",
|
"admin.partner_units.feedback.updated": "Aktualisiert.",
|
||||||
"admin.partner_units.feedback.deleted": "Gelöscht.",
|
"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.heading": "Mitglieder verwalten",
|
||||||
"admin.partner_units.member.empty": "Noch keine Mitglieder.",
|
"admin.partner_units.member.empty": "Noch keine Mitglieder.",
|
||||||
"admin.partner_units.member.add": "Mitglied hinzufügen",
|
"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.remove": "Entfernen",
|
||||||
"admin.partner_units.member.confirm_remove": "Mitglied entfernen?",
|
"admin.partner_units.member.confirm_remove": "Mitglied entfernen?",
|
||||||
"admin.partner_units.member.placeholder": "Name oder E-Mail",
|
"admin.partner_units.member.placeholder": "Name oder E-Mail",
|
||||||
|
"admin.partner_units.member.role": "Rolle",
|
||||||
"admin.audit.loading": "Lade…",
|
"admin.audit.loading": "Lade…",
|
||||||
"admin.audit.empty": "Keine Ereignisse für die gewählten Filter.",
|
"admin.audit.empty": "Keine Ereignisse für die gewählten Filter.",
|
||||||
"admin.audit.loadmore": "Weitere laden",
|
"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.created": "Created.",
|
||||||
"admin.partner_units.feedback.updated": "Updated.",
|
"admin.partner_units.feedback.updated": "Updated.",
|
||||||
"admin.partner_units.feedback.deleted": "Deleted.",
|
"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.heading": "Manage members",
|
||||||
"admin.partner_units.member.empty": "No members yet.",
|
"admin.partner_units.member.empty": "No members yet.",
|
||||||
"admin.partner_units.member.add": "Add member",
|
"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.remove": "Remove",
|
||||||
"admin.partner_units.member.confirm_remove": "Remove member?",
|
"admin.partner_units.member.confirm_remove": "Remove member?",
|
||||||
"admin.partner_units.member.placeholder": "Name or email",
|
"admin.partner_units.member.placeholder": "Name or email",
|
||||||
|
"admin.partner_units.member.role": "Role",
|
||||||
"admin.audit.loading": "Loading…",
|
"admin.audit.loading": "Loading…",
|
||||||
"admin.audit.empty": "No events match the selected filters.",
|
"admin.audit.empty": "No events match the selected filters.",
|
||||||
"admin.audit.loadmore": "Load more",
|
"admin.audit.loadmore": "Load more",
|
||||||
|
|||||||
@@ -47,14 +47,21 @@ interface ProjectTeamMember {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// t-paliad-139 — derived team member from a partner-unit attachment.
|
// 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 {
|
interface DerivedMember {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_email: string;
|
user_email: string;
|
||||||
user_display_name: string;
|
user_display_name: string;
|
||||||
user_office: string;
|
user_office: string;
|
||||||
unit_role: string;
|
memberships: DerivedMembership[];
|
||||||
unit_id: string;
|
|
||||||
unit_name: string;
|
|
||||||
derive_grants_authority: boolean;
|
derive_grants_authority: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1736,7 +1743,25 @@ function renderDerivedMembers() {
|
|||||||
section.style.display = "";
|
section.style.display = "";
|
||||||
body.innerHTML = derivedMembers
|
body.innerHTML = derivedMembers
|
||||||
.map((m) => {
|
.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 officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
|
||||||
const authBadge = m.derive_grants_authority
|
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>`
|
? `<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>
|
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||||
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
||||||
<td><span class="projekt-team-role">${esc(roleLabel)}</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>`;
|
</tr>`;
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ export type I18nKey =
|
|||||||
| "admin.partner_units.error.user_required"
|
| "admin.partner_units.error.user_required"
|
||||||
| "admin.partner_units.feedback.created"
|
| "admin.partner_units.feedback.created"
|
||||||
| "admin.partner_units.feedback.deleted"
|
| "admin.partner_units.feedback.deleted"
|
||||||
|
| "admin.partner_units.feedback.role_updated"
|
||||||
| "admin.partner_units.feedback.updated"
|
| "admin.partner_units.feedback.updated"
|
||||||
| "admin.partner_units.heading"
|
| "admin.partner_units.heading"
|
||||||
| "admin.partner_units.loading"
|
| "admin.partner_units.loading"
|
||||||
@@ -177,6 +178,7 @@ export type I18nKey =
|
|||||||
| "admin.partner_units.member.heading"
|
| "admin.partner_units.member.heading"
|
||||||
| "admin.partner_units.member.placeholder"
|
| "admin.partner_units.member.placeholder"
|
||||||
| "admin.partner_units.member.remove"
|
| "admin.partner_units.member.remove"
|
||||||
|
| "admin.partner_units.member.role"
|
||||||
| "admin.partner_units.new"
|
| "admin.partner_units.new"
|
||||||
| "admin.partner_units.new.heading"
|
| "admin.partner_units.new.heading"
|
||||||
| "admin.partner_units.subtitle"
|
| "admin.partner_units.subtitle"
|
||||||
|
|||||||
@@ -8911,6 +8911,44 @@ dialog.quick-add-sheet::backdrop {
|
|||||||
background: var(--color-bg-lime-tint);
|
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 {
|
.admin-team-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.3rem 0.45rem;
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -49,18 +50,49 @@ type AttachedUnit struct {
|
|||||||
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
|
DerivedMemberCount int `db:"derived_member_count" json:"derived_member_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DerivedMember is one user who currently derives onto a project via an
|
// DerivedMembership is one (unit, role) pair through which a user currently
|
||||||
// attached partner unit. Used by the Team tab "Abgeleitet (Partner Unit)"
|
// derives onto a project. A multi-unit user has one DerivedMembership per
|
||||||
// subsection.
|
// 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 {
|
type DerivedMember struct {
|
||||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||||
Email string `db:"email" json:"user_email"`
|
Email string `db:"email" json:"user_email"`
|
||||||
DisplayName string `db:"display_name" json:"user_display_name"`
|
DisplayName string `db:"display_name" json:"user_display_name"`
|
||||||
Office string `db:"office" json:"user_office"`
|
Office string `db:"office" json:"user_office"`
|
||||||
UnitRole string `db:"unit_role" json:"unit_role"`
|
Memberships DerivedMembershipList `db:"memberships" json:"memberships"`
|
||||||
UnitID uuid.UUID `db:"unit_id" json:"unit_id"`
|
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
|
||||||
UnitName string `db:"unit_name" json:"unit_name"`
|
|
||||||
DeriveGrantsAuthority bool `db:"derive_grants_authority" json:"derive_grants_authority"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachUnitOptions controls how a unit is attached. Empty values use the
|
// 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
|
// down to descendants — derivation honours the same direction as
|
||||||
// can_see_project.
|
// can_see_project.
|
||||||
//
|
//
|
||||||
// Dedupe: if the same user derives via multiple (unit, ancestor) pairs,
|
// One row per user. Multi-unit users (e.g. a PA working across two partner
|
||||||
// the closest-attachment row wins (smallest path-distance). One row per
|
// units, both of which are attached to the project's path) carry every
|
||||||
// user.
|
// (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) {
|
func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]DerivedMember, error) {
|
||||||
project, err := s.projects.GetByID(ctx, callerID, projectID)
|
project, err := s.projects.GetByID(ctx, callerID, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -209,44 +244,40 @@ func (s *DerivationService) ListDerivedMembers(ctx context.Context, callerID, pr
|
|||||||
SELECT ppu.project_id AS attach_project_id,
|
SELECT ppu.project_id AS attach_project_id,
|
||||||
ppu.partner_unit_id,
|
ppu.partner_unit_id,
|
||||||
ppu.derive_unit_roles,
|
ppu.derive_unit_roles,
|
||||||
ppu.derive_grants_authority,
|
ppu.derive_grants_authority
|
||||||
array_position($1::uuid[], ppu.project_id) AS depth_rank
|
|
||||||
FROM paliad.project_partner_units ppu
|
FROM paliad.project_partner_units ppu
|
||||||
WHERE ppu.project_id = ANY($1::uuid[])
|
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,
|
u.email, u.display_name, u.office,
|
||||||
r.unit_role,
|
jsonb_agg(DISTINCT jsonb_build_object(
|
||||||
r.partner_unit_id AS unit_id,
|
'unit_id', a.partner_unit_id,
|
||||||
pu.name AS unit_name,
|
'unit_name', pu.name,
|
||||||
r.derive_grants_authority
|
'unit_role', pum.unit_role
|
||||||
FROM ranked r
|
)) AS memberships,
|
||||||
JOIN paliad.users u ON u.id = r.user_id
|
bool_or(a.derive_grants_authority) AS derive_grants_authority
|
||||||
JOIN paliad.partner_units pu ON pu.id = r.partner_unit_id
|
FROM attached a
|
||||||
WHERE r.rn = 1
|
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`,
|
ORDER BY u.display_name`,
|
||||||
pq.StringArray(ancestorIDs))
|
pq.StringArray(ancestorIDs))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("list derived members: %w", err)
|
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
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user