Files
paliad/internal/services/caldav_ical.go
m deef5aaff5 feat(t-paliad-138): CalDAV [PENDING] prefix + reminder digest pending banner
Commit 7 of 8. Outbound surfaces honour the pending-approval state
instead of going silent on it.

CalDAV (caldav_ical.go formatAppointment): when an appointment is
approval_status='pending', the iCal SUMMARY line is prefixed with
"[PENDING] ". External clients (Outlook, Apple Calendar, etc.) thus
display the unverified state honestly. Approved entries sync clean.

Email reminder digest (reminder_service.go):
- digestRow gains ApprovalStatus, sourced from f.approval_status in
  the SELECT.
- Each pending row's Title is rewritten to "[PENDING] <title>" before
  it lands in the template — visible in every email-rendered list.
- Template data carries PendingCount (count of pending rows in this
  digest) + InboxURL so future template revisions can render a
  banner like "Hinweis: N Frist(en) wartet auf 4-Augen-Genehmigung —
  /inbox" without further code changes. Existing templates unchanged
  for backwards compat; the prefix on row titles already conveys the
  signal.
- IsPending flag on each item map for future per-row template
  conditionals.

Rationale: silence on a pending change is the worst outcome for a
4-eye system. The user's external calendar and reminder mail must
reflect "this exists but isn't verified" so they can act before the
deadline lapses.
2026-05-06 16:07:14 +02:00

216 lines
5.7 KiB
Go

package services
import (
"bufio"
"fmt"
"strings"
"time"
"mgit.msbls.de/m/paliad/internal/models"
)
// Minimal RFC 5545 (iCalendar) writer + reader for VEVENT blocks.
//
// Why hand-rolled instead of github.com/emersion/go-ical?
// - The Appointment schema is small (Title, Description, Location, Start, End,
// plus the UID Paliad generates) so a 100-line formatter does the job.
// - Avoids two third-party dependencies (go-ical + go-webdav) for ~6
// iCal properties and 4 WebDAV verbs.
//
// Property escaping: per RFC 5545 §3.3.11 we escape `\` `;` `,` and `\n`
// in TEXT values; everything else passes through. Lines are not folded
// (folding is optional per §3.1).
const (
calProductID = "-//Paliad//Paliad Appointments//EN"
calVersion = "2.0"
icalDateUTC = "20060102T150405Z"
)
// terminUID is the canonical CalDAV UID for a Paliad Appointment. Paliad-owned
// events round-trip through this format; foreign events have arbitrary
// UIDs and are ignored on pull.
func terminUID(id string) string {
return "paliad-appointment-" + id + "@paliad.de"
}
// extractAppointmentID returns the Paliad Appointment id (uuid string) embedded in a
// terminUID, or "" when the UID isn't ours.
func extractAppointmentID(uid string) string {
const prefix = "paliad-appointment-"
const suffix = "@paliad.de"
if !strings.HasPrefix(uid, prefix) || !strings.HasSuffix(uid, suffix) {
return ""
}
return uid[len(prefix) : len(uid)-len(suffix)]
}
// formatAppointment renders a single VCALENDAR + VEVENT for an Appointment.
// Output uses CRLF line endings as required by RFC 5545.
func formatAppointment(t *models.Appointment) string {
var b strings.Builder
w := func(line string) {
b.WriteString(line)
b.WriteString("\r\n")
}
w("BEGIN:VCALENDAR")
w("PRODID:" + calProductID)
w("VERSION:" + calVersion)
w("BEGIN:VEVENT")
w("UID:" + terminUID(t.ID.String()))
w("DTSTAMP:" + time.Now().UTC().Format(icalDateUTC))
w("DTSTART:" + t.StartAt.UTC().Format(icalDateUTC))
if t.EndAt != nil {
w("DTEND:" + t.EndAt.UTC().Format(icalDateUTC))
}
// Prepend "[PENDING] " on the SUMMARY when the appointment is awaiting
// 4-eye approval (t-paliad-138). External clients (Outlook etc.) thus
// reflect the unverified state honestly — silence on a pending change
// would be a worse outcome than visible-but-flagged.
summary := t.Title
if t.ApprovalStatus == "pending" {
summary = "[PENDING] " + t.Title
}
w("SUMMARY:" + escapeText(summary))
if t.Description != nil && *t.Description != "" {
w("DESCRIPTION:" + escapeText(*t.Description))
}
if t.Location != nil && *t.Location != "" {
w("LOCATION:" + escapeText(*t.Location))
}
w("END:VEVENT")
w("END:VCALENDAR")
return b.String()
}
func escapeText(s string) string {
r := strings.NewReplacer(
`\`, `\\`,
";", `\;`,
",", `\,`,
"\n", `\n`,
"\r", "",
)
return r.Replace(s)
}
func unescapeText(s string) string {
// Order matters: \\ → marker → final
out := strings.Builder{}
for i := 0; i < len(s); i++ {
c := s[i]
if c == '\\' && i+1 < len(s) {
next := s[i+1]
switch next {
case 'n', 'N':
out.WriteByte('\n')
case ';', ',', '\\':
out.WriteByte(next)
default:
out.WriteByte(c)
out.WriteByte(next)
}
i++
continue
}
out.WriteByte(c)
}
return out.String()
}
// parsedEvent is the subset of VEVENT fields Paliad cares about.
type parsedEvent struct {
UID string
Summary string
Description string
Location string
DTStart *time.Time
DTEnd *time.Time
}
// parseICalendar walks the byte stream and returns one parsed VEVENT per
// occurrence. Robust against folded lines (RFC 5545 §3.1) — a leading
// space/tab continues the previous line.
func parseICalendar(body string) ([]parsedEvent, error) {
out := []parsedEvent{}
sc := bufio.NewScanner(strings.NewReader(body))
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
var unfolded []string
for sc.Scan() {
line := strings.TrimRight(sc.Text(), "\r")
if len(line) == 0 {
continue
}
if (line[0] == ' ' || line[0] == '\t') && len(unfolded) > 0 {
unfolded[len(unfolded)-1] += line[1:]
continue
}
unfolded = append(unfolded, line)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("scan ical: %w", err)
}
var current *parsedEvent
for _, line := range unfolded {
switch {
case line == "BEGIN:VEVENT":
current = &parsedEvent{}
case line == "END:VEVENT":
if current != nil {
out = append(out, *current)
}
current = nil
case current != nil:
name, value := splitProperty(line)
switch strings.ToUpper(name) {
case "UID":
current.UID = value
case "SUMMARY":
current.Summary = unescapeText(value)
case "DESCRIPTION":
current.Description = unescapeText(value)
case "LOCATION":
current.Location = unescapeText(value)
case "DTSTART":
if t, ok := parseICalTime(value); ok {
current.DTStart = &t
}
case "DTEND":
if t, ok := parseICalTime(value); ok {
current.DTEnd = &t
}
}
}
}
return out, nil
}
// splitProperty splits "NAME[;params]:value" into ("NAME", "value"),
// ignoring all parameters (Paliad rounds-trip in UTC so TZID is unused).
func splitProperty(line string) (string, string) {
colon := strings.IndexByte(line, ':')
if colon < 0 {
return line, ""
}
head := line[:colon]
value := line[colon+1:]
if semi := strings.IndexByte(head, ';'); semi >= 0 {
head = head[:semi]
}
return head, value
}
// parseICalTime accepts "20240101T120000Z", "20240101T120000" (floating),
// or "20240101" (date-only) and returns UTC.
func parseICalTime(value string) (time.Time, bool) {
formats := []string{icalDateUTC, "20060102T150405", "20060102"}
for _, f := range formats {
if t, err := time.Parse(f, value); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}