Paliadin can now draft deadlines + appointments through two new owner-gated HTTP endpoints. Drafted entities land in the existing approval pipeline as approval_status='pending' with requester_kind='agent' + agent_turn_id linking back to the chat turn that produced the suggestion. The user reviews via the same eye-pill 👀 surface (with ✨ added in Slice E). POST /api/paliadin/suggest/deadline POST /api/paliadin/suggest/appointment Wiring: - ApprovalService.SubmitAgentCreate — agent variant of SubmitCreate; always creates an approval_request (bypassing policy lookup) and stamps requester_kind='agent' + agent_turn_id. Required-role defaults to 'associate' so the deadlock check has a non-NULL threshold; m's lock-in for Q11 (every agent suggestion needs the user's eye) means bypassing the policy gate is correct here, not a regression. - The shared `submit` kernel takes an optional agent_turn_id pointer. All four lifecycle entry points (SubmitCreate / SubmitUpdate / SubmitComplete / SubmitDelete) pass nil; SubmitAgentCreate passes the turn id. INSERT to approval_requests now writes both requester_kind + agent_turn_id atomically (xor-check on the schema enforces consistency). - models.ApprovalRequest grows the two columns + their JSON tags so the inbox view + Verlauf renderer can read provenance without an extra fetch. - approvalRequestViewColumns adds ar.requester_kind + ar.agent_turn_id to the SQL projection; both surfaces (ListPendingForApprover, ListSubmittedByUser, GetRequest) inherit the new fields free. - CreateDeadlineInput + CreateAppointmentInput each get an optional AgentTurnID *uuid.UUID. When non-nil, the create-tx routes through SubmitAgentCreate instead of the regular SubmitCreate. Default-zero behaviour is unchanged for every existing caller. - handlers/paliadin_suggest.go is the new HTTP layer. Owner-gated via requirePaliadinOwner (same gate /paliadin uses), JSON-bodied, RFC3339 + ISO-date validation, 409 + a useful message on ErrNoQualifiedApprover. - Project-event audit metadata gains requester_kind + agent_turn_id so the project's Verlauf can render "Paliadin hat eine Frist vorgeschlagen ✨" without joining approval_requests (Slice E reads this). SKILL.md (~/.claude/skills/paliadin/SKILL.md) gains an "Agent-suggested writes" section with the tool catalog, behaviour rules ("never write directly", confirmation in the response file, project_id lookup discipline, RFC3339 dates, no chained tool calls per turn), and the 409 error contract. go build + go vet + go test all clean. No frontend changes in this slice — Slice E lights up the ✨ on existing eye-pill surfaces. Refs: docs/design-paliadin-inline-2026-05-08.md §7.
869 lines
30 KiB
Go
869 lines
30 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
|
|
approvals *ApprovalService
|
|
}
|
|
|
|
// SetApprovalService wires the optional 4-eye approval workflow
|
|
// (t-paliad-138). See DeadlineService.SetApprovalService.
|
|
func (s *AppointmentService) SetApprovalService(a *ApprovalService) {
|
|
s.approvals = a
|
|
}
|
|
|
|
// pendingApprovalErr enriches ErrConcurrentPending with the in-flight
|
|
// request id + required role for a 409 hint. Falls back to the bare
|
|
// ErrConcurrentPending when approvals is unwired or the lookup fails.
|
|
func (s *AppointmentService) pendingApprovalErr(ctx context.Context, appointmentID uuid.UUID) error {
|
|
if s.approvals == nil {
|
|
return ErrConcurrentPending
|
|
}
|
|
rid, role, err := s.approvals.PendingRequestForEntity(ctx, EntityTypeAppointment, appointmentID)
|
|
if err != nil || rid == "" {
|
|
return ErrConcurrentPending
|
|
}
|
|
return NewPendingApprovalError(rid, role)
|
|
}
|
|
|
|
// 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, completed_at,
|
|
approval_status, pending_request_id, approved_by, approved_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"`
|
|
// AgentTurnID, when non-nil, marks the create as a Paliadin-drafted
|
|
// suggestion (t-paliad-161). Same semantics as
|
|
// CreateDeadlineInput.AgentTurnID.
|
|
AgentTurnID *uuid.UUID `json:"agent_turn_id,omitempty"`
|
|
}
|
|
|
|
// UpdateAppointmentInput is the partial-update payload for PATCH /api/appointments/{id}.
|
|
//
|
|
// ProjectID + ClearProject control the project move (t-paliad-140). Both
|
|
// nil/false = leave project_id untouched. ClearProject=true unlinks the
|
|
// appointment from its current project (only the creator may do this,
|
|
// matching the personal-appointment edit gate). ProjectID set = move under
|
|
// that project (visibility on the new project is enforced).
|
|
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"`
|
|
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
|
ClearProject bool `json:"clear_project,omitempty"`
|
|
}
|
|
|
|
// AppointmentListFilter narrows ListVisibleForUser results.
|
|
//
|
|
// CreatedBy narrows to appointments whose `created_by = id`. Backs the
|
|
// "Nur persönliche" filter on /events (t-paliad-128). It composes with
|
|
// the team-visibility predicate (AND) rather than replacing it, so an
|
|
// appointment a user created on a team they have since left still
|
|
// won't leak through.
|
|
//
|
|
// DirectOnly narrows ProjectID from "this project + every descendant" (the
|
|
// t-paliad-139 subtree default) to "this project only" (t-paliad-152).
|
|
// Has no effect when ProjectID is nil.
|
|
type AppointmentListFilter struct {
|
|
ProjectID *uuid.UUID
|
|
From *time.Time
|
|
To *time.Time
|
|
Type *string
|
|
CreatedBy *uuid.UUID
|
|
DirectOnly bool
|
|
}
|
|
|
|
// 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 {
|
|
if filter.DirectOnly {
|
|
conds = append(conds, `t.project_id = :project_id`)
|
|
} else {
|
|
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
|
|
}
|
|
if filter.CreatedBy != nil {
|
|
conds = append(conds, `t.created_by = :created_by`)
|
|
args["created_by"] = *filter.CreatedBy
|
|
}
|
|
|
|
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,
|
|
t.completed_at,
|
|
t.approval_status, t.pending_request_id, t.approved_by, t.approved_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 Project (visibility-checked).
|
|
//
|
|
// When directOnly is false (default), the result aggregates appointments
|
|
// from the Project itself AND every descendant Project (per the
|
|
// t-paliad-139 hierarchy aggregation contract). When directOnly is true,
|
|
// only appointments whose project_id exactly equals the filter are
|
|
// returned.
|
|
//
|
|
// The descendant aggregation mirrors DeadlineService.ListForProject — see
|
|
// the doc comment there for the rationale.
|
|
func (s *AppointmentService) ListForProject(ctx context.Context, userID, projectID uuid.UUID, directOnly bool) ([]models.Appointment, error) {
|
|
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
|
return nil, err
|
|
}
|
|
rows := []models.Appointment{}
|
|
var filter string
|
|
if directOnly {
|
|
filter = `WHERE project_id = $1`
|
|
} else {
|
|
filter = `WHERE project_id IN (
|
|
SELECT p.id FROM paliad.projects p
|
|
WHERE $1 = ANY(string_to_array(p.path, '.')::uuid[]))`
|
|
}
|
|
if err := s.db.SelectContext(ctx, &rows,
|
|
`SELECT `+appointmentColumns+`
|
|
FROM paliad.appointments
|
|
`+filter+`
|
|
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
|
|
}
|
|
|
|
// Approval gate (t-paliad-138). No-op for personal appointments
|
|
// (project_id IS NULL) and when no policy applies.
|
|
//
|
|
// Agent-suggested path (t-paliad-161): when input.AgentTurnID is
|
|
// set, the row goes through the agent-create variant which always
|
|
// creates a request (bypassing the policy gate) and stamps the
|
|
// request with requester_kind='agent' + the originating turn id.
|
|
if s.approvals != nil {
|
|
payload := map[string]any{"title": title, "start_at": input.StartAt.UTC().Format(time.RFC3339)}
|
|
if input.AgentTurnID != nil {
|
|
if _, err := s.approvals.SubmitAgentCreate(ctx, tx, *input.ProjectID, id, userID, *input.AgentTurnID, EntityTypeAppointment, payload); err != nil {
|
|
return nil, err
|
|
}
|
|
} else {
|
|
if _, err := s.approvals.SubmitCreate(ctx, tx, *input.ProjectID, id, userID, EntityTypeAppointment, payload); 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.
|
|
//
|
|
// Approval gate (t-paliad-138): only date-bearing fields (start_at,
|
|
// end_at) trigger 4-eye per Q4. Cosmetic edits (title, description,
|
|
// location, appointment_type) bypass approval. Personal appointments
|
|
// (project_id IS NULL) never gate — there's no project policy to consult.
|
|
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
|
|
}
|
|
if current.ApprovalStatus == ApprovalStatusPending {
|
|
return nil, s.pendingApprovalErr(ctx, appointmentID)
|
|
}
|
|
|
|
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++
|
|
}
|
|
|
|
preImage := map[string]any{}
|
|
payload := map[string]any{}
|
|
|
|
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 {
|
|
newStart := input.StartAt.UTC()
|
|
if !newStart.Equal(current.StartAt) {
|
|
preImage["start_at"] = current.StartAt.Format(time.RFC3339)
|
|
payload["start_at"] = newStart.Format(time.RFC3339)
|
|
}
|
|
appendSet("start_at", newStart)
|
|
}
|
|
if input.EndAt != nil {
|
|
newEnd := input.EndAt.UTC()
|
|
oldEnd := time.Time{}
|
|
if current.EndAt != nil {
|
|
oldEnd = *current.EndAt
|
|
}
|
|
if !newEnd.Equal(oldEnd) {
|
|
if current.EndAt != nil {
|
|
preImage["end_at"] = current.EndAt.Format(time.RFC3339)
|
|
} else {
|
|
preImage["end_at"] = nil
|
|
}
|
|
payload["end_at"] = newEnd.Format(time.RFC3339)
|
|
}
|
|
appendSet("end_at", newEnd)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// Project move (t-paliad-140). ClearProject takes precedence over
|
|
// ProjectID so a payload that sets both falls into the unlink branch
|
|
// rather than silently ignoring the contradiction. Visibility on the
|
|
// destination is enforced via projects.GetByID (matches Create).
|
|
// Unlinking to a personal appointment is creator-only — same gate
|
|
// personal-only Update branches enforce above — so a non-creator who
|
|
// can mutate the project-attached row can't strand it on someone else's
|
|
// personal calendar.
|
|
var movedFromProject *uuid.UUID
|
|
var movedToProject *uuid.UUID
|
|
if input.ClearProject {
|
|
if current.ProjectID != nil {
|
|
if current.CreatedBy == nil || *current.CreatedBy != userID {
|
|
return nil, fmt.Errorf("%w: only the creator can convert this Appointment to personal", ErrForbidden)
|
|
}
|
|
from := *current.ProjectID
|
|
movedFromProject = &from
|
|
appendSet("project_id", nil)
|
|
}
|
|
} else if input.ProjectID != nil {
|
|
if current.ProjectID == nil || *input.ProjectID != *current.ProjectID {
|
|
if _, err := s.projects.GetByID(ctx, userID, *input.ProjectID); err != nil {
|
|
return nil, err
|
|
}
|
|
to := *input.ProjectID
|
|
movedToProject = &to
|
|
if current.ProjectID != nil {
|
|
from := *current.ProjectID
|
|
movedFromProject = &from
|
|
}
|
|
appendSet("project_id", *input.ProjectID)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// Audit emission. Project moves (t-paliad-140) get their own
|
|
// appointment_project_changed pair so the OLD and NEW project rows
|
|
// keep an honest chronology. Edits to other fields land as
|
|
// appointment_updated on whichever project the row sits on AFTER the
|
|
// move (or on the source project if it was unlinked). Personal
|
|
// appointments don't have audit history, so unlink/link rows on the
|
|
// "personal" side are skipped.
|
|
desc := current.Title
|
|
descPtr := &desc
|
|
if movedFromProject != nil || movedToProject != nil {
|
|
moveMeta := map[string]any{"appointment_id": appointmentID}
|
|
if movedFromProject != nil {
|
|
moveMeta["from_project_id"] = *movedFromProject
|
|
}
|
|
if movedToProject != nil {
|
|
moveMeta["to_project_id"] = *movedToProject
|
|
}
|
|
if movedFromProject != nil {
|
|
if err := insertProjectEventWithMeta(ctx, tx, *movedFromProject, userID,
|
|
"appointment_project_changed", "Appointment project changed", descPtr, moveMeta); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if movedToProject != nil {
|
|
if err := insertProjectEventWithMeta(ctx, tx, *movedToProject, userID,
|
|
"appointment_project_changed", "Appointment project changed", descPtr, moveMeta); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
otherFieldsTouched := input.Title != nil || input.Description != nil ||
|
|
input.StartAt != nil || input.EndAt != nil || input.Location != nil ||
|
|
input.AppointmentType != nil
|
|
if otherFieldsTouched {
|
|
// After-move project. If the row is now personal (unlink), no
|
|
// audit row — personal appointments don't surface in any
|
|
// project's Verlauf.
|
|
var auditProject *uuid.UUID
|
|
switch {
|
|
case movedToProject != nil:
|
|
auditProject = movedToProject
|
|
case movedFromProject != nil:
|
|
// Unlink: no audit project
|
|
default:
|
|
auditProject = current.ProjectID
|
|
}
|
|
if auditProject != nil {
|
|
if err := insertProjectEventWithMeta(ctx, tx, *auditProject, userID, "appointment_updated", "Appointment updated", descPtr,
|
|
map[string]any{"appointment_id": appointmentID}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if s.approvals != nil {
|
|
if _, err := s.approvals.SubmitUpdate(ctx, tx, *current.ProjectID, appointmentID, userID, EntityTypeAppointment, preImage, payload); 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.
|
|
//
|
|
// Approval gate (t-paliad-138): for project-attached appointments, if a
|
|
// (project, appointment, delete) policy applies, the row stays alive
|
|
// with approval_status='pending' until the approver hard-deletes
|
|
// (approve) or restores it (reject) — same stage-then-write exception
|
|
// as DeadlineService.Delete.
|
|
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
|
|
}
|
|
if current.ApprovalStatus == ApprovalStatusPending {
|
|
return s.pendingApprovalErr(ctx, appointmentID)
|
|
}
|
|
|
|
tx, err := s.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Approval gate runs first for project-attached appointments. If a
|
|
// policy applies, SubmitDelete returns a non-nil request id and we
|
|
// skip the hard delete + the deletion event emit.
|
|
var pendingRequest *uuid.UUID
|
|
if current.ProjectID != nil && s.approvals != nil {
|
|
preImage := map[string]any{
|
|
"title": current.Title,
|
|
"start_at": current.StartAt.Format(time.RFC3339),
|
|
}
|
|
req, err := s.approvals.SubmitDelete(ctx, tx, *current.ProjectID, appointmentID, userID, EntityTypeAppointment, preImage)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
pendingRequest = req
|
|
}
|
|
|
|
if pendingRequest == nil {
|
|
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 pendingRequest == nil && 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
|
|
}
|