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, ar.requester_kind AS requester_kind FROM paliad.appointments t LEFT JOIN paliad.projects p ON p.id = t.project_id LEFT JOIN paliad.approval_requests ar ON ar.id = t.pending_request_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 }