t-paliad-244 / m/paliad#75. Both "E-Mail an Auswahl senden" actions on
/team (filter-bar + bottom selection footer) now branch on canBroadcast():
- Admin path keeps the in-app compose modal (POST /api/team/broadcast).
- Non-admin path renders a native <a href="mailto:..."> with the
recipient list pre-filled, comma-joined and URL-encoded via
buildMailtoHref (already exported from broadcast.ts).
Filter-bar button used to hide for non-admins; it now shows as the
mailto: anchor and its href refreshes on every filter change so the link
always matches what's visible. Empty visible set disables the affordance
visually (aria-disabled + pointer-events:none) so a click can't open an
empty composer. Bottom selection footer mirrors the same shape.
No new i18n keys, no backend changes, admin compose flow untouched.
#53 — adds an explicit selection layer ON TOP of the existing filter
pills on /team. Frontend-only; no backend changes, no migration.
- frontend/src/team.tsx: master "Alle sichtbaren auswählen" checkbox row above the team-list.
- frontend/src/client/team.ts:
- Module-scoped selectedUserIDs Set + renderedUserIDs DOM-order snapshot + lastToggledUserID for Shift-click range expansion.
- renderUserCard gains a per-row checkbox + data-selected attribute mirroring the Set.
- pruneSelectionToVisible(): every render() drops user_ids that no longer match the filter — invariant "selection ⊆ visible".
- wireSelectionCheckboxes() + applyRangeSelection() + refreshCardSelectedAttribute(): plain-click toggles one row, Shift-click extends a contiguous range using renderedUserIDs as the order reference.
- renderSelectionFooter(): fixed-position bar that mounts when selection > 0, hides when empty. Hosts the live "{n} ausgewählt" counter, a "Auswahl aufheben" reset, and an "E-Mail an Auswahl" button that calls openBroadcastModal with selectedRecipients() — reuses the existing modal verbatim.
- syncMasterCheckbox() + onMasterToggle(): tri-state master checkbox (empty / partial / full) for "select all visible".
- frontend/src/styles/global.css: .team-card[data-selected="true"] highlight, .team-card-select checkbox cell, .team-select-master-row, .team-selection-footer (z-index 150 — above mobile bottom-nav at 100, well below modal overlays at 1000+).
- i18n: +10 keys (team.selection.{count,clear,send,select_all,toggle_card}) × DE + EN.
Design picks honoured: surface=/team not /admin/team (Q1), checkbox column not modifier-key (Q2), sticky footer not always-visible (Q3), drop-out de-selects on filter change (Q4), fallback to filtered set when selection empty preserved by leaving the existing top-bar broadcast button intact (Q5), wipe on navigation since the Set is module-scoped in-memory only (Q6).
bun run build clean (2543 i18n keys, data-i18n scan clean). go build + go test -short ./internal/... unchanged (no backend touched).
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.
Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
template_key, recipient_filter jsonb, recipient_user_ids uuid[],
send_report jsonb, sent_at). RLS: senders read own rows, global_admin
reads all; inserts must self-attribute. No CHECK-constraint extension to
partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
persists audit row, dispatches via 5-deep goroutine pool with 15s
per-send timeout. Send report (sent/failed counts + per-recipient errors)
is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
*italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
first; only whitelisted tags re-emitted. Script tags and javascript:
URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
{{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
index. Powers the /team project multi-select filter without N round
trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
/admin/broadcasts is gateOnboarded (not adminGate) so leads can see
their own sends; the service applies the per-row visibility filter.
Frontend
- /team gains a project multi-select chip dropdown (visible projects
loaded from /api/projects, intersected against the memberships index)
alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
true (global_admin always; non-admin needs lead-ship on selected
projects, or at least one project when no filter is set). Server still
re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
template dropdown (loads existing email templates and strips Go-template
directives) + recipient preview (first 5 + expand) + send. Hard-blocks
empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
detail (subject, body, recipient list, send_report counts).
Tests
- broadcast_service_test.go: placeholder substitution table-driven,
Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
validation cases (empty subject/body, recipient cap, invalid email),
signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
rules (lead can send on own project, member cannot, admin sees all,
member can't read lead's row). Skips when TEST_DATABASE_URL is unset.
i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
Adds a Role filter row alongside the existing Office row on /team. Pills
are rendered from the distinct paliad.users.job_title values present in
the loaded users; "Alle" + Partner / Counsel / Senior Associate /
Associate / … / PA / Paralegal in seniority order, anything unrecognised
sorted alphabetically after.
The interface field name was previously `role: string`, left over from
before t-paliad-051 split paliad.users.role into job_title +
global_role. The API has been returning `job_title` since then, so the
role line on every card was silently empty. Updated User /
DepartmentMember interfaces to `job_title?: string | null`, and
renderUserCard now displays it via roleLabel(). Search now matches
job_title too.
Role values are normalised case-insensitively (DB still has both
"Associate" and "associate" today — separate cleanup), and a roleLabel()
helper looks up team.role.<slug> with the raw job_title as fallback so
new titles render even before the i18n entry exists.
Files
- frontend/src/team.tsx — second team-filter-row
- frontend/src/client/team.ts — User.job_title, ROLE_ORDER,
presentRoles, buildRoleFilters, userMatchesRole, roleLabel; render()
intersects office × role × search
- frontend/src/client/i18n.ts — team.filter.role + 10 team.role.* keys
(DE/EN)
F-8 from the t-paliad-074 audit. Replaces silent `?? key` fallback with a
typed key surface so drift caught at compile/build time, not in prod.
- New `frontend/src/i18n-keys.ts` (generated): `I18nKey` literal union of
all 1288 keys in `i18n.ts`. Regenerated by `frontend/build.ts` on every
build; written only when content changes (no spurious diffs).
- `t(key: I18nKey)` is now strict — `t("fristn.detail.title")` fails
`tsc --noEmit`. New `tDyn(key: string)` is the explicit escape hatch
for runtime-composed keys (`tDyn(\`fristen.status.${x}\`)`); 27 dynamic
call sites migrated.
- Build-time scan in `build.ts` walks `src/**/*.{ts,tsx}` for literal
`data-i18n` / `data-i18n-placeholder` / `data-i18n-title` attributes
and aborts the build on any value not in the key set. Skips `${...}`
interpolations (can't resolve statically). Applied before bundling so
no artefact ships when an unknown literal is present.
Surfaced and fixed during migration:
- `data-i18n="fristen.save.modal.project"` (fristenrechner.ts:145) →
`fristen.save.modal.akte` — F-04-class bug, would render the raw key.
- `t("termine.field.project.none")` (appointments-new.ts:30) →
`termine.field.akte.none` — same class.
- `t("checklisten.instance.project.open")` (checklists-instance.ts:155)
→ `checklisten.instance.akte.open` — same class.
- 4 duplicate-key entries in `i18n.ts` removed (TS1117): `nav.termine`
and `akten.detail.tab.termine` each appeared twice in DE and twice in
EN with identical values.
Out of scope (per brief): the German-vs-English i18n-key namespace split
flagged as F-9, JSX intrinsic typing, and the `akten` → `projects`
half-rename in checklists-detail.ts. Those stay tsc-noisy until separate
tasks land.
Frontend half of the rename:
- New /admin/partner-units page (admin-partner-units.tsx + .ts) with
full CRUD + member management. Mirrors /admin/team's aesthetic and
uses the same modal pattern. Card on /admin flips from "Geplant"
to "Verfügbar" with ICON_BUILDING and a /admin/partner-units link.
- Sidebar gains a "Partner Units" admin nav item between Team and Audit.
- Onboarding form replaces the free-text Dezernat input with a select
populated from /api/partner-units; submits partner_unit_id which the
backend uses to insert a membership row in the user-create tx.
- Settings: dezernat tab removed entirely (TabName drops to 3). The
read-only "Meine Partner Units" view now lives as a card on the
profile tab. Free-text dezernat input removed from the profile form.
~250 lines of admin-CRUD removed; replaced by ~70 lines of read-only
partner-units summary.
- /admin/team: Dezernat column dropped from the table and the inline
edit row; "Onboard existing account" modal no longer asks for one.
Column count drops from 10 to 9.
- /team directory: groups by structured partner_unit_members only;
drops the free-text fallback grouping and the "Ohne Dezernat" loose
bucket. Single "Ohne Partner Unit" orphan group catches users in no
unit.
- i18n: ~30 dezernat.* + onboarding.dezernat + admin.team.col.dezernat
+ admin.card.departments + team.* keys removed; ~30 partner_unit.*
keys added in DE+EN. "Partner Unit" / "Partner Units" used as a
loanword in DE.
- /api/departments?include=members → /api/partner-units?include=members
in team.ts (the only frontend-side fetch URL referencing the old
endpoints).
go build / vet / test clean. cd frontend && bun run build clean.
Adds /team page that lists every onboarded Paliad user, grouped by office
(default) or by department, with a free-text search and per-office filter
pills. Each card shows display name, role, primary office (with any
additional offices), department tag, and a mailto: link.
Backend:
- /api/users now also returns additional_offices (column was already on the
model + DB; just missing from the SELECT list).
- /api/departments?include=members returns each department enriched with
its lead user snapshot and the full member list — single fetch for the
"by department" grouping.
- New page handler /team behind the onboarding gate.
Frontend:
- frontend/src/team.tsx + frontend/src/client/team.ts (new) for the page
shell and client-side rendering / filtering.
- New "Team" entry in the Übersicht sidebar group with a users icon.
- DE/EN i18n keys (nav.team, team.*).
- Team-specific CSS for cards, group headers, avatars, and badges.