Files
paliad/internal/services/team_service.go
m 52ee319fd8 feat(t-paliad-147): bulk team email — send to filtered selection from /team page
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).
2026-05-07 20:58:57 +02:00

251 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package services
// TeamService manages paliad.project_teams — project team memberships.
//
// Inheritance model (t-paliad-024): a user added at any ancestor of a Project
// is implicitly a member of every descendant. Writes only ever touch the
// direct level; inherited memberships are computed at read time by walking
// UP the materialised path.
//
// The `inherited` column in the DB is reserved for potential future caching
// of inherited rows. This service does not write inherited=true rows.
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
// TeamService reads and writes paliad.project_teams.
type TeamService struct {
db *sqlx.DB
projects *ProjectService
}
// NewTeamService wires the service.
func NewTeamService(db *sqlx.DB, projects *ProjectService) *TeamService {
return &TeamService{db: db, projects: projects}
}
// AddMember inserts a direct team membership. The caller must have visibility
// on the Project (RLS + service-layer gate). Role defaults to 'associate'
// if empty. Idempotent on (project_id, user_id) — a repeat call updates role.
func (s *TeamService) AddMember(ctx context.Context, callerID, projectID, userID uuid.UUID, role string) (*models.ProjectTeamMember, error) {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return nil, err
}
if role == "" {
role = RoleAssociate
}
if !isValidRole(role) {
return nil, fmt.Errorf("%w: invalid role %q", ErrInvalidInput, role)
}
var m models.ProjectTeamMember
err := s.db.GetContext(ctx, &m,
`INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
VALUES ($1, $2, $3, false, $4)
ON CONFLICT (project_id, user_id) DO UPDATE
SET role = EXCLUDED.role
RETURNING id, project_id, user_id, role, inherited, added_by, created_at`,
projectID, userID, role, callerID)
if err != nil {
return nil, fmt.Errorf("add team member: %w", err)
}
return &m, nil
}
// RemoveMember deletes a direct team membership. Inherited memberships (from
// ancestors) can't be removed at the child level — the caller must remove
// the ancestor row to break the inheritance.
func (s *TeamService) RemoveMember(ctx context.Context, callerID, projectID, userID uuid.UUID) error {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return err
}
res, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.project_teams
WHERE project_id = $1 AND user_id = $2 AND inherited = false`,
projectID, userID)
if err != nil {
return fmt.Errorf("remove team member: %w", err)
}
if rows, _ := res.RowsAffected(); rows == 0 {
return sql.ErrNoRows
}
return nil
}
// ListDirectMembers returns only the direct (non-inherited) team members,
// enriched with user display fields.
func (s *TeamService) ListDirectMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) {
if _, err := s.projects.GetByID(ctx, callerID, projectID); err != nil {
return nil, err
}
rows := []models.ProjectTeamMemberWithUser{}
err := s.db.SelectContext(ctx, &rows,
`SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.inherited,
pt.added_by, pt.created_at,
u.email AS user_email,
u.display_name AS user_display_name,
u.office AS user_office,
NULL::uuid AS inherited_from_id,
NULL::text AS inherited_from_title
FROM paliad.project_teams pt
LEFT JOIN paliad.users u ON u.id = pt.user_id
WHERE pt.project_id = $1
ORDER BY pt.role, u.display_name`, projectID)
if err != nil {
return nil, fmt.Errorf("list direct team: %w", err)
}
return rows, nil
}
// ListEffectiveMembers returns direct + inherited members of a Project.
// Rows coming from an ancestor carry Inherited=true + InheritedFromID/Title.
// If the same user is both direct and inherited, the direct row wins.
func (s *TeamService) ListEffectiveMembers(ctx context.Context, callerID, projectID uuid.UUID) ([]models.ProjectTeamMemberWithUser, error) {
project, err := s.projects.GetByID(ctx, callerID, projectID)
if err != nil {
return nil, err
}
ancestorIDs := pathToIDStrings(project.Path)
query := `
WITH candidate AS (
SELECT pt.id, pt.project_id, pt.user_id, pt.role, pt.added_by, pt.created_at,
(pt.project_id <> $1) AS inherited,
CASE WHEN pt.project_id <> $1 THEN pt.project_id END AS inherited_from_id,
CASE WHEN pt.project_id <> $1 THEN parent.title END AS inherited_from_title
FROM paliad.project_teams pt
LEFT JOIN paliad.projects parent ON parent.id = pt.project_id
WHERE pt.project_id = ANY($2::uuid[])
),
ranked AS (
SELECT c.*, ROW_NUMBER() OVER (
PARTITION BY c.user_id
ORDER BY c.inherited ASC, c.created_at ASC
) AS rn FROM candidate c
)
SELECT r.id, r.project_id, r.user_id, r.role, r.inherited,
r.added_by, r.created_at,
u.email AS user_email,
u.display_name AS user_display_name,
u.office AS user_office,
r.inherited_from_id,
r.inherited_from_title
FROM ranked r
LEFT JOIN paliad.users u ON u.id = r.user_id
WHERE r.rn = 1
ORDER BY r.inherited ASC, r.role, u.display_name`
rows := []models.ProjectTeamMemberWithUser{}
if err := s.db.SelectContext(ctx, &rows, query, projectID, pq.StringArray(ancestorIDs)); err != nil {
return nil, fmt.Errorf("list effective team: %w", err)
}
return rows, nil
}
// MembershipEntry is one row in the team-memberships index.
// Powers the /team page project-multi-select filter (t-paliad-147):
// the frontend pulls the index once, then filters users locally
// by intersecting the UI-selected project_ids against each user's
// project_ids list.
type MembershipEntry struct {
UserID uuid.UUID `json:"user_id"`
ProjectIDs []string `json:"project_ids"`
// LeadProjectIDs is the subset of project_ids on which this
// user has role='lead'. Surfaces the "I am a lead on N projects"
// state the broadcast send-button needs.
LeadProjectIDs []string `json:"lead_project_ids"`
// Role on each project — same indexing as project_ids — so the
// frontend can offer a project_teams.role filter.
Roles []string `json:"roles"`
}
// ListMembershipsIndex returns one row per user × project_team membership
// the caller can see. global_admin sees everything; non-admin only sees
// memberships on projects whose visibility predicate they pass.
//
// Membership rows are direct (paliad.project_teams.project_id) only —
// inherited memberships are left to the client to compute, since the
// project-multi-select filter wants "user is on this exact project"
// semantics, not "user inherits from somewhere up the tree".
func (s *TeamService) ListMembershipsIndex(ctx context.Context, callerID uuid.UUID) ([]MembershipEntry, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT pt.user_id::text, pt.project_id::text, pt.role
FROM paliad.project_teams pt
JOIN paliad.projects p ON p.id = pt.project_id
WHERE `+visibilityPredicatePositional("p", 1)+`
ORDER BY pt.user_id, pt.project_id`,
callerID,
)
if err != nil {
return nil, fmt.Errorf("list memberships index: %w", err)
}
defer rows.Close()
byUser := map[uuid.UUID]*MembershipEntry{}
for rows.Next() {
var userIDStr, projectIDStr, role string
if err := rows.Scan(&userIDStr, &projectIDStr, &role); err != nil {
return nil, fmt.Errorf("scan membership: %w", err)
}
uid, err := uuid.Parse(userIDStr)
if err != nil {
continue
}
entry, ok := byUser[uid]
if !ok {
entry = &MembershipEntry{UserID: uid}
byUser[uid] = entry
}
entry.ProjectIDs = append(entry.ProjectIDs, projectIDStr)
entry.Roles = append(entry.Roles, role)
if role == RoleLead {
entry.LeadProjectIDs = append(entry.LeadProjectIDs, projectIDStr)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iter memberships: %w", err)
}
out := make([]MembershipEntry, 0, len(byUser))
for _, e := range byUser {
out = append(out, *e)
}
return out, nil
}
// ---------------------------------------------------------------------------
func isValidRole(r string) bool {
switch r {
case RoleLead, RoleAssociate, RolePA, RoleOfCounsel,
RoleLocalCounsel, RoleExpert, RoleObserver:
return true
}
return false
}
// pathToIDStrings splits a materialised path into its UUID labels as strings,
// suitable for pq.StringArray → uuid[] cast.
func pathToIDStrings(path string) []string {
if path == "" {
return nil
}
parts := strings.Split(path, ".")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p != "" {
out = append(out, p)
}
}
return out
}