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).
251 lines
8.6 KiB
Go
251 lines
8.6 KiB
Go
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
|
||
}
|