Files
paliad/internal/services/appointment_service.go
m a69fff73e9 feat(t-paliad-124): project filter includes descendant projects
Selecting a Client in the project filter now returns rows attached to
that Client AND every Litigation / Patent / Case below it (and so on
down the tree). Previously the filter was exact-match: picking a Client
hid every item in the subtree, which was the opposite of what users
expect when they pick a parent in a hierarchical picker.

The descendant set comes from paliad.projects.path - every project's
path always contains its own id and every ancestor's id, so any project
whose path includes the filter UUID is either that project or a
descendant. Pattern matches the existing visibility predicate (which
walks the path UPWARD for inheritance); the new helper just inverts the
direction.

Filter sites updated:
  - DeadlineService.ListVisibleForUser     (/deadlines, /events)
  - DeadlineService.SummaryCounts          (deadline summary cards)
  - AppointmentService.ListVisibleForUser  (/appointments, /events)
  - EventService.deadlineBuckets           (/events deadline rail)
  - EventService.appointmentBuckets        (/events appointment rail)

ListForProject (deadline/appointment/checklist/note) is unchanged - it
fetches items for ONE specific project on the project detail page, not
a filter.

Visibility predicate (paliad.can_see_project) untouched - that walks
upward and is a different concern.
2026-05-04 18:57:06 +02:00

635 lines
21 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// AppointmentService reads and writes paliad.appointments.
//
// Visibility:
// - project_id IS NULL → personal Appointment, visible/editable only to created_by
// - project_id IS NOT NULL → follows ProjectService.GetByID team gate
//
// Audit: Project-attached mutations append project_events rows. Personal
// Appointments never touch project_events.
//
// CalDAV: optional hook (AppointmentCalDAVPusher) is called best-effort after
// each mutation.
type AppointmentService struct {
db *sqlx.DB
projects *ProjectService
caldav AppointmentCalDAVPusher
}
// AppointmentCalDAVPusher is the contract the CalDAV service implements so the
// AppointmentService can push individual appointment changes without importing the
// caldav package directly.
type AppointmentCalDAVPusher interface {
OnAppointmentCreated(ctx context.Context, userID uuid.UUID, t *models.Appointment)
OnAppointmentUpdated(ctx context.Context, userID uuid.UUID, t *models.Appointment)
OnAppointmentDeleted(ctx context.Context, userID uuid.UUID, t *models.Appointment)
}
func NewAppointmentService(db *sqlx.DB, projects *ProjectService) *AppointmentService {
return &AppointmentService{db: db, projects: projects}
}
// SetCalDAVPusher wires an optional CalDAV push hook.
func (s *AppointmentService) SetCalDAVPusher(p AppointmentCalDAVPusher) {
s.caldav = p
}
const appointmentColumns = `id, project_id, title, description, start_at, end_at,
location, appointment_type, caldav_uid, caldav_etag, created_by,
created_at, updated_at`
// CreateAppointmentInput is the payload for POST /api/appointments.
type CreateAppointmentInput struct {
ProjectID *uuid.UUID `json:"project_id,omitempty"`
Title string `json:"title"`
Description *string `json:"description,omitempty"`
StartAt time.Time `json:"start_at"`
EndAt *time.Time `json:"end_at,omitempty"`
Location *string `json:"location,omitempty"`
AppointmentType *string `json:"appointment_type,omitempty"`
}
// UpdateAppointmentInput is the partial-update payload for PATCH /api/appointments/{id}.
type UpdateAppointmentInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
StartAt *time.Time `json:"start_at,omitempty"`
EndAt *time.Time `json:"end_at,omitempty"`
Location *string `json:"location,omitempty"`
AppointmentType *string `json:"appointment_type,omitempty"`
}
// AppointmentListFilter narrows ListVisibleForUser results.
type AppointmentListFilter struct {
ProjectID *uuid.UUID
From *time.Time
To *time.Time
Type *string
}
// ListVisibleForUser returns all Appointments the user can see (personal +
// Project-attached they have visibility for), ordered by start_at ascending.
func (s *AppointmentService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter AppointmentListFilter) ([]models.AppointmentWithProject, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.AppointmentWithProject{}, nil
}
visibility := `(
(t.project_id IS NULL AND t.created_by = :user_id)
OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `)
)`
conds := []string{visibility}
args := map[string]any{
"user_id": userID,
}
if filter.ProjectID != nil {
conds = append(conds, projectDescendantPredicate("p"))
args["project_id"] = *filter.ProjectID
}
if filter.From != nil {
conds = append(conds, `t.start_at >= :from`)
args["from"] = *filter.From
}
if filter.To != nil {
conds = append(conds, `t.start_at <= :to`)
args["to"] = *filter.To
}
if filter.Type != nil {
if !isValidAppointmentType(*filter.Type) {
return nil, fmt.Errorf("%w: invalid appointment_type %q", ErrInvalidInput, *filter.Type)
}
conds = append(conds, `t.appointment_type = :type`)
args["type"] = *filter.Type
}
query := `
SELECT t.id, t.project_id, t.title, t.description, t.start_at, t.end_at,
t.location, t.appointment_type, t.caldav_uid, t.caldav_etag,
t.created_by, t.created_at, t.updated_at,
p.reference AS project_reference,
p.title AS project_title,
p.type AS project_type
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY t.start_at ASC, t.created_at DESC`
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare list appointments: %w", err)
}
defer stmt.Close()
rows := []models.AppointmentWithProject{}
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list appointments: %w", err)
}
return rows, nil
}
// ListForProject returns Appointments for a specific Project, visibility-checked.
func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Appointment, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
return nil, err
}
rows := []models.Appointment{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+appointmentColumns+`
FROM paliad.appointments
WHERE project_id = $1
ORDER BY start_at ASC, created_at DESC`, projectID); err != nil {
return nil, fmt.Errorf("list appointments for project: %w", err)
}
return rows, nil
}
// CanSee reports whether the user has visibility on the Appointment. Returns
// (false, nil) for invisible or missing — handlers must not distinguish.
// Cheaper than GetByID when only the visibility bit is needed (no projection
// of the full row); used by sibling services (NoteService, etc.) to gate on
// the parent without paying for a full SELECT plus a follow-up project read.
func (s *AppointmentService) CanSee(ctx context.Context, userID, appointmentID uuid.UUID) (bool, error) {
var visible bool
query := `SELECT EXISTS (
SELECT 1
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE t.id = $1 AND (
(t.project_id IS NULL AND t.created_by = $2)
OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 2) + `)
)
)`
if err := s.db.GetContext(ctx, &visible, query, appointmentID, userID); err != nil {
return false, fmt.Errorf("check appointment visibility: %w", err)
}
return visible, nil
}
// GetByID returns a single Appointment if the user has visibility.
func (s *AppointmentService) GetByID(ctx context.Context, userID, appointmentID uuid.UUID) (*models.Appointment, error) {
var t models.Appointment
err := s.db.GetContext(ctx, &t,
`SELECT `+appointmentColumns+` FROM paliad.appointments WHERE id = $1`, appointmentID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("fetch appointment: %w", err)
}
if !s.canSee(ctx, userID, &t) {
return nil, ErrNotVisible
}
return &t, nil
}
// requireMutationRole enforces the partner/admin gate on Project-linked
// Appointment mutations. The Appointment's own creator is also allowed.
func (s *AppointmentService) requireMutationRole(ctx context.Context, userID uuid.UUID, t *models.Appointment) error {
if t.CreatedBy != nil && *t.CreatedBy == userID {
return nil
}
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrNotVisible
}
if user.GlobalRole != "global_admin" {
return fmt.Errorf("%w: only partners/admins can modify Appointments on a Project", ErrForbidden)
}
return nil
}
// canSee mirrors the SELECT visibility predicate for one in-memory Appointment.
func (s *AppointmentService) canSee(ctx context.Context, userID uuid.UUID, t *models.Appointment) bool {
if t.ProjectID == nil {
return t.CreatedBy != nil && *t.CreatedBy == userID
}
_, err := s.projects.GetByID(ctx, userID, *t.ProjectID)
return err == nil
}
// Create inserts a Appointment. If project_id is set, ProjectService visibility
// is enforced and the Project's audit trail records the new appointment.
func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input CreateAppointmentInput) (*models.Appointment, error) {
title := strings.TrimSpace(input.Title)
if title == "" {
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if input.StartAt.IsZero() {
return nil, fmt.Errorf("%w: start_at is required", ErrInvalidInput)
}
if input.EndAt != nil && input.EndAt.Before(input.StartAt) {
return nil, fmt.Errorf("%w: end_at must be after start_at", ErrInvalidInput)
}
if input.AppointmentType != nil && *input.AppointmentType != "" && !isValidAppointmentType(*input.AppointmentType) {
return nil, fmt.Errorf("%w: invalid appointment_type %q", ErrInvalidInput, *input.AppointmentType)
}
if input.ProjectID != nil {
if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
return nil, err
}
}
id := uuid.New()
now := time.Now().UTC()
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.appointments
(id, project_id, title, description, start_at, end_at, location,
appointment_type, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)`,
id, input.ProjectID, title, input.Description, input.StartAt.UTC(),
nullableUTC(input.EndAt), input.Location, input.AppointmentType, userID, now,
); err != nil {
return nil, fmt.Errorf("insert appointment: %w", err)
}
if input.ProjectID != nil {
// Description carries value-only payload (the appointment title); frontend
// renders via the localized event.description.appointment_* template. Same
// pattern for updated/deleted below. Metadata carries the appointment id
// so the Verlauf entry deep-links to /appointments/{id} (t-paliad-102).
desc := title
descPtr := &desc
if err := insertProjectEventWithMeta(ctx, tx, *input.ProjectID, userID, "appointment_created", "Appointment created", descPtr,
map[string]any{"appointment_id": id}); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit insert appointment: %w", err)
}
t, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
if s.caldav != nil {
s.caldav.OnAppointmentCreated(ctx, userID, t)
}
return t, nil
}
// Update applies a partial update.
func (s *AppointmentService) Update(ctx context.Context, userID, appointmentID uuid.UUID, input UpdateAppointmentInput) (*models.Appointment, error) {
current, err := s.GetByID(ctx, userID, appointmentID)
if err != nil {
return nil, err
}
if current.ProjectID == nil {
if current.CreatedBy == nil || *current.CreatedBy != userID {
return nil, fmt.Errorf("%w: only the creator can edit a personal Appointment", ErrForbidden)
}
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
return nil, err
}
sets := []string{}
args := []any{}
next := 1
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if input.Title != nil {
title := strings.TrimSpace(*input.Title)
if title == "" {
return nil, fmt.Errorf("%w: title cannot be empty", ErrInvalidInput)
}
appendSet("title", title)
}
if input.Description != nil {
appendSet("description", *input.Description)
}
if input.StartAt != nil {
appendSet("start_at", input.StartAt.UTC())
}
if input.EndAt != nil {
appendSet("end_at", input.EndAt.UTC())
}
if input.Location != nil {
appendSet("location", *input.Location)
}
if input.AppointmentType != nil {
if *input.AppointmentType != "" && !isValidAppointmentType(*input.AppointmentType) {
return nil, fmt.Errorf("%w: invalid appointment_type %q", ErrInvalidInput, *input.AppointmentType)
}
appendSet("appointment_type", *input.AppointmentType)
}
if len(sets) == 0 {
return current, nil
}
appendSet("updated_at", time.Now().UTC())
args = append(args, appointmentID)
query := fmt.Sprintf("UPDATE paliad.appointments SET %s WHERE id = $%d",
strings.Join(sets, ", "), next)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update appointment: %w", err)
}
if current.ProjectID != nil {
desc := current.Title
descPtr := &desc
if err := insertProjectEventWithMeta(ctx, tx, *current.ProjectID, userID, "appointment_updated", "Appointment updated", descPtr,
map[string]any{"appointment_id": appointmentID}); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update appointment: %w", err)
}
t, err := s.GetByID(ctx, userID, appointmentID)
if err != nil {
return nil, err
}
if s.caldav != nil {
s.caldav.OnAppointmentUpdated(ctx, userID, t)
}
return t, nil
}
// Delete removes an Appointment.
func (s *AppointmentService) Delete(ctx context.Context, userID, appointmentID uuid.UUID) error {
current, err := s.GetByID(ctx, userID, appointmentID)
if err != nil {
return err
}
if current.ProjectID == nil {
if current.CreatedBy == nil || *current.CreatedBy != userID {
return fmt.Errorf("%w: only the creator can delete a personal Appointment", ErrForbidden)
}
} else if err := s.requireMutationRole(ctx, userID, current); err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.appointments WHERE id = $1`, appointmentID); err != nil {
return fmt.Errorf("delete appointment: %w", err)
}
if current.ProjectID != nil {
desc := current.Title
descPtr := &desc
if err := insertProjectEvent(ctx, tx, *current.ProjectID, userID, "appointment_deleted", "Appointment deleted", descPtr); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit delete appointment: %w", err)
}
if s.caldav != nil {
s.caldav.OnAppointmentDeleted(ctx, userID, current)
}
return nil
}
// AppointmentSummaryCounts buckets visible Appointments into today / this_week / later.
type AppointmentSummaryCounts struct {
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
Later int `json:"later" db:"later"`
Total int `json:"total" db:"total"`
}
// SummaryCounts aggregates Appointments by start-date bucket for the user's visible projects.
func (s *AppointmentService) SummaryCounts(ctx context.Context, userID uuid.UUID) (*AppointmentSummaryCounts, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return &AppointmentSummaryCounts{}, nil
}
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
tomorrow := today.AddDate(0, 0, 1)
endOfWeek := today.AddDate(0, 0, 7)
query := `
SELECT
COUNT(*) FILTER (WHERE t.start_at >= :today AND t.start_at < :tomorrow) AS today,
COUNT(*) FILTER (WHERE t.start_at >= :tomorrow AND t.start_at < :endweek) AS this_week,
COUNT(*) FILTER (WHERE t.start_at >= :endweek) AS later,
COUNT(*) FILTER (WHERE t.start_at >= :today) AS total
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE
(t.project_id IS NULL AND t.created_by = :user_id)
OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `)`
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare appointment summary: %w", err)
}
defer stmt.Close()
var c AppointmentSummaryCounts
if err := stmt.GetContext(ctx, &c, map[string]any{
"today": today,
"tomorrow": tomorrow,
"endweek": endOfWeek,
"user_id": userID,
}); err != nil {
return nil, fmt.Errorf("appointment summary: %w", err)
}
return &c, nil
}
// SetCalDAVMeta is called by the CalDAV service after a successful push.
func (s *AppointmentService) SetCalDAVMeta(ctx context.Context, appointmentID uuid.UUID, uid, etag string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE paliad.appointments
SET caldav_uid = $1, caldav_etag = $2, updated_at = NOW()
WHERE id = $3`, uid, etag, appointmentID)
if err != nil {
return fmt.Errorf("update appointment caldav meta: %w", err)
}
return nil
}
// AllForUser returns every Appointment (personal + visible Project-attached) the
// user owns. Used by the CalDAV push loop.
func (s *AppointmentService) AllForUser(ctx context.Context, userID uuid.UUID) ([]models.Appointment, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return nil, nil
}
rows := []models.Appointment{}
query := `
SELECT ` + appointmentColumns + `
FROM paliad.appointments t
LEFT JOIN paliad.projects p ON p.id = t.project_id
WHERE
(t.project_id IS NULL AND t.created_by = $1)
OR (t.project_id IS NOT NULL AND ` + visibilityPredicatePositional("p", 1) + `)`
if err := s.db.SelectContext(ctx, &rows, query, userID); err != nil {
return nil, fmt.Errorf("all appointments for user: %w", err)
}
return rows, nil
}
// FindByCalDAVUID resolves a Appointment from its external UID.
func (s *AppointmentService) FindByCalDAVUID(ctx context.Context, uid string) (*models.Appointment, error) {
var t models.Appointment
err := s.db.GetContext(ctx, &t,
`SELECT `+appointmentColumns+` FROM paliad.appointments WHERE caldav_uid = $1`, uid)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotVisible
}
if err != nil {
return nil, fmt.Errorf("find appointment by caldav uid: %w", err)
}
return &t, nil
}
// ApplyRemoteUpdate writes pulled CalDAV changes into the local row.
func (s *AppointmentService) ApplyRemoteUpdate(ctx context.Context, appointmentID uuid.UUID, title, description, location *string, startAt, endAt *time.Time, etag string) (bool, error) {
sets := []string{"caldav_etag = $1", "updated_at = NOW()"}
args := []any{etag}
next := 2
changed := false
if title != nil {
sets = append(sets, fmt.Sprintf("title = $%d", next))
args = append(args, *title)
next++
changed = true
}
if description != nil {
sets = append(sets, fmt.Sprintf("description = $%d", next))
args = append(args, *description)
next++
changed = true
}
if location != nil {
sets = append(sets, fmt.Sprintf("location = $%d", next))
args = append(args, *location)
next++
changed = true
}
if startAt != nil {
sets = append(sets, fmt.Sprintf("start_at = $%d", next))
args = append(args, startAt.UTC())
next++
changed = true
}
if endAt != nil {
sets = append(sets, fmt.Sprintf("end_at = $%d", next))
args = append(args, endAt.UTC())
next++
changed = true
}
args = append(args, appointmentID)
query := fmt.Sprintf("UPDATE paliad.appointments SET %s WHERE id = $%d",
strings.Join(sets, ", "), next)
if _, err := s.db.ExecContext(ctx, query, args...); err != nil {
return false, fmt.Errorf("apply remote appointment update: %w", err)
}
return changed, nil
}
// DeleteByCalDAVUID removes an Appointment pulled-deleted from the remote calendar.
func (s *AppointmentService) DeleteByCalDAVUID(ctx context.Context, uid string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.appointments WHERE caldav_uid = $1`, uid)
if err != nil {
return fmt.Errorf("delete appointment by caldav uid: %w", err)
}
return nil
}
// LogConflict appends a conflict event to the parent Project's audit trail.
// No-op for personal Appointments.
func (s *AppointmentService) LogConflict(ctx context.Context, appointmentID uuid.UUID, msg string) error {
var row struct {
ProjectID *uuid.UUID `db:"project_id"`
CreatedBy *uuid.UUID `db:"created_by"`
}
err := s.db.GetContext(ctx, &row,
`SELECT project_id, created_by FROM paliad.appointments WHERE id = $1`, appointmentID)
if err != nil || row.ProjectID == nil {
return nil //nolint:nilerr
}
now := time.Now().UTC()
desc := msg
_, err = s.db.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at)
VALUES ($1, $2, 'caldav_conflict', 'CalDAV conflict', $3, $4, $5, '{}', $4, $4)`,
uuid.New(), *row.ProjectID, desc, now, row.CreatedBy)
if err != nil {
return fmt.Errorf("insert caldav conflict event: %w", err)
}
return nil
}
// users returns the shared user service via the Project handle.
func (s *AppointmentService) users() *UserService {
return s.projects.Users()
}
func nullableUTC(t *time.Time) any {
if t == nil {
return nil
}
u := t.UTC()
return u
}
func isValidAppointmentType(t string) bool {
switch t {
case "hearing", "meeting", "consultation", "deadline_hearing":
return true
}
return false
}