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.
216 lines
5.7 KiB
Go
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
|
|
}
|