reminder_service.go: BuildDigest audience predicate switches the
"project lead anywhere on the path" branch from `pt.role = 'lead'` to
`pt.responsibility = 'lead'`. Two SQL sites + comment updated.
deadline_service.go: assertCanAdminProject (Reopen permission) switches
from `pt.role IN ('admin','lead')` to `pt.responsibility = 'lead'`.
The legacy 'admin' was already dead since t-paliad-051 — never present
in project_teams.role to begin with — so this also drops a slow leak.
Doc comments + error message updated.
derivation_service.go: ListDescendantStaffed SELECTs both `pt.role` and
`pt.responsibility`, returns the new column to the team-tab "from
descendants" subsection (so the firm-tier badge + responsibility pill
both render). ORDER BY switches to responsibility.
Build + vet clean. Pure-Go tests pass.
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.
F-6 from t-paliad-074 architecture audit. The Gitea repo was renamed
m/patholo → mAi/paliad → m/paliad, but go.mod still declared
`mgit.msbls.de/m/patholo` and every internal import echoed the
pre-rebrand name.
Sweep:
- go.mod: module path → mgit.msbls.de/m/paliad
- All *.go files: imports rewritten via sed
- README.md, docs/design-kanzlai-integration.md: mAi/paliad → m/paliad
- Frontend issue-reference comments (mAi/paliad#N → m/paliad#N) in
i18n.ts, theme.ts, sidebar.ts, app.ts, Sidebar.tsx, PWAHead.tsx,
global.css
Verified: go build/vet/test ./... clean, bun run build clean,
no remaining mgit.msbls.de/m/patholo or mAi/paliad references
outside docs that intentionally describe the rename history.
Pre-fix `time.NewTicker(time.Hour)` fired every hour from container start,
so a Dokploy redeploy at 13:27:50 produced ticks at HH:27:50 forever —
drifting the user-visible arrival of a 09:00-Berlin digest anywhere in the
09:xx hour, and entirely losing the slot when redeploys happened to land
during the slot's hour (m saw this on 2026-04-29).
Replace the simple ticker with a recompute-per-iteration sleep to the next
HH:00:00 boundary using nextTopOfHour(now). The recompute self-corrects
any clock skew or RunOnce duration rather than accumulating drift.
Add runStartupCatchUp: on boot, fire any user/slot whose hour has already
passed today but has no log row yet. The slot_date dedup makes this safe
(re-firing a logged slot is a no-op). Without this a single mistimed
redeploy still loses a day for affected users.
Tests: TestNextTopOfHour (boundary math, including the 13:27:50 signature
and sub-second offsets), TestNextTopOfHour_AlwaysLandsOnBoundary (fuzz
across an hour of offsets), TestNextTopOfHour_StableAfterRunOnce (confirms
the next fire is HH+1:00 after a fire at HH:00, not HH:00+delay1),
TestSlotPastDueToday (catch-up filter table), and a live-DB
TestRunStartupCatchUp_RecoversMissedMorningSlot covering the redeploy-at-
11:50-Berlin scenario plus dedup on a second startup the same day.
DB-backed email-template editor for global_admins, replacing the
"Kommt bald" placeholder. Admins can edit invitation, deadline_digest,
and the shared base wrapper for both DE and EN, preview against sample
data, save with versions, and reset to the embedded default.
Backend:
- Migration 026 adds paliad.email_templates (active row per (key, lang))
and paliad.email_template_versions (append-only, retained 20 deep).
- EmailTemplateService — GetActive falls through to the embedded per-
language file when no DB row, Save validates parse + structural
invariants and writes a version, Reset deletes the active row, Restore
copies a version back. Mutations require DB; reads work without.
- MailService now consults the service for body and subject and falls
back to the embedded default if the active row is malformed at parse
time — a corrupt admin save can never wedge the send path.
- Subjects move from Go (buildDigestSubject + inviteSubject) to
text/template strings stored in the (key, lang) row. Default subjects
ship with a {{/* keep this phrasing */}} comment pointing at the
reminder-redesign doc so the SLO framing rationale survives edits.
- Bilingual templates split into per-language files (invitation.de.html
+ .en.html, deadline_digest.de.html + .en.html, base.de.html + .en.html).
No more {{if eq .Lang}} branching inside templates.
- Handlers under /api/admin/email-templates/* gated by the existing
RequireAdminFunc(users) admin middleware, same shape as /admin/team.
Frontend:
- /admin/email-templates list page — three cards (one per template),
each linking to DE + EN editors with their last-modified status.
- /admin/email-templates/{key}?lang=de three-pane editor — subject + body
textarea + variable docs + actions on the left, sandboxed iframe
preview + version log on the right. 500 ms debounced live preview;
save validates server-side (422 on parse error, surfaced inline).
- admin.tsx flips the Email-Templates card from PLANNED to verfügbar.
- 50 new i18n keys (DE + EN) for the editor surface.
Tests: GetActive fallback path, ValidateTemplate happy + sad paths,
SaveRequiresStore on no-DB service, RenderTemplate body + subject
goldens, full SYSTEMAUSFALL/SYSTEM FAILURE subject matrix.
Smoke (knowledge-platform-only run, no DB/auth):
- GET /admin/email-templates → 302 to /login
- GET /api/admin/email-templates → 401
- go build/vet/test clean, bun run build clean
Design: docs/design-email-templates-2026-04-29.md.
$2 was the offset, used only in the morning dateCond. Evening's query
referenced $1, $3, $4 — $2 was passed but unused, and Postgres can't
infer the type of an unreferenced parameter ('could not determine data
type of parameter $2', 42P18).
Inline offset directly into the morning dateCond as a literal '%d days'
(safe — it's clamped to ≥1 above). New positional layout:
$1 = today
$2 = userid
$3 = is_global_admin
Three rounds of SQL fights for one query. Adding integration coverage
to TestRunSlotForUser is a follow-up — it currently skips when
TEST_DATABASE_URL is unset, which is why none of these reached prod via
CI.
Previous fix replaced $arg with :arg per sqlx.Named convention but the
query body contains PostgreSQL `::TYPE` cast operators (`::uuid[]`,
`::date`, `::interval`). sqlx.Named eats the second `:` thinking it's
a named-arg prefix → 'syntax error at or near ":"'.
Switching to positional $1..$4 sidesteps sqlx.Named entirely. Args
passed directly to db.SelectContext.
Order: $1=today, $2=offset, $3=userid, $4=is_global_admin.
Same root cause as 1652436 — the $/literal token form was the right
intuition; the proper fix is positional, not :name.
fetchSlotDeadlines built the query with $today_arg / $userid_arg /
$offset_arg / $is_global_admin_arg placeholders. sqlx.Named only
recognises :name (colon prefix). Postgres got the literal $arg and
rejected with 'syntax error at or near "$"' on every tick — that's
why no reminder_log row has been written since the t-paliad-064 deploy
yesterday.
Changed all four placeholders to :name. The integration test that would
have caught this (TestRunSlotForUser) skips when TEST_DATABASE_URL is
unset, so CI never hit the live SQL.
Today's morning slot is permanently lost (hourly-tick design issue
tracked in t-paliad-069). Deploying now while Berlin hour=16 should
fire m's evening slot immediately on container startup via the
Start()→RunOnce() call.
Exposes paliad.users.escalation_contact_id (added in migration 025) via
the Benachrichtigungen tab so users can route DRINGEND/overdue
escalation to a specific colleague instead of the global_admins
fallback.
Service:
- UpdateProfileInput.EscalationContactID *string (empty = clear, matches
Dezernat tri-state pattern). Server-side validation rejects self-
pointer (also enforced by CHECK in migration 025) and unknown UUIDs.
Reminder read path:
- digestRow now carries owner.escalation_contact_id and the audience
predicate adds the override. visibleForCategory's "global admin"
branch suppresses when an override is set, so escalation does not
fan out to the whole admin team. Test table extended with override
cases (escalation contact sees overdue / DRINGEND, admin suppressed).
UI / client:
- New "Eskalations-Kontakt" section under Benachrichtigungen with a
select populated from /api/users (excluding self, sorted by name).
First option is the default-fallback marker; selecting it clears.
- savePrefs PATCHes escalation_contact_id alongside the existing
reminder fields.
i18n: einstellungen.prefs.escalation.{heading,hint,default_option}
in DE + EN.
docs/project-status.md: flips the open follow-up to "shipped".
Replaces the per-deadline reminder model (overdue / tomorrow /
due_today_evening / weekly templates and four per-kind send paths) with
one bundled digest per (user, slot, local-date) — owner + project leads +
global_admins as audience tiers, three category sections per email.
Service rewrite (internal/services/reminder_service.go):
- RunOnce iterates users, evaluates morning/evening slot per user's tz,
calls runSlotForUser for each match.
- runSlotForUser checks the slot+date dedup (migration 025), fetches the
three pending-deadline categories visible to u (overdue / due_today /
due_warning at u.reminder_warning_offset_days), composes a digest, and
inserts the dedup row only on successful send.
- Audience filter applied per row in Go: due_warning to owner/lead,
due_today to owner/lead (+global_admin in evening), overdue to
owner/global_admin (NOT lead — system failure escalates past the team).
- Subject ladder: ÜBERFÄLLIG / SYSTEMAUSFALL when overdues are in the
bundle; DRINGEND on evening when due_today still pending; "Frist-
Erinnerung: N offen" otherwise. EN equivalents.
- Retired sendPerFrist, sendWeekly, deliverFristReminder, deliverWeekly,
buildSubject, slotForKind, matchesLocalDueDate.
Templates:
- Added deadline_digest.html with three category sections (red/amber/
neutral), DRINGEND wording on evening, IsOtherOwner attribution row.
- Removed deadline_reminder.html, deadline_due_today.html, deadline_weekly.html.
User schema (Go side):
- models.User gains ReminderWarningOffsetDays (int, default 7) and
EscalationContactID (*uuid.UUID, nullable).
- userColumns SELECT updated; UpdateProfileInput accepts the new offset
with 1..30 validation.
Settings → Notifications UI (PR-4):
- New reminder categories: overdue / due_today / due_warning. Legacy
toggles (tomorrow, due_today_evening, weekly) removed and the legacy
pref keys are explicitly deleted from the email_preferences object on
next save so they don't linger.
- New "Vorwarnung (Tage vorher)" input (1..30, required), wired into the
PATCH /api/me payload as reminder_warning_offset_days.
- Times-section copy refreshed: "Morgen-Slot" / "Abend-Slot (Eskalation)"
with new hint text reflecting the bundled-digest model.
- DE + EN i18n strings added/updated.
Tests:
- TestCategorize, TestVisibleForCategory, TestBuildDigestSubject lock
the boundary, recipient-rule, and subject-ladder logic.
- TestRunSlotForUser (live DB, skipped without TEST_DATABASE_URL) covers
the morning/evening flow, slot+date dedup, and off-slot tick.
- TestRunSlotForUser_EmptyDigest enforces the no-spam rule.
- TestDeliverDigest_RendersTemplate runs the new template on the
digestRow shape so a typo would fail before any SMTP I/O.
- TestRenderTemplateDeadlineDigest replaces the deleted reminder/weekly
template tests.
go build/vet/test + bun run build all clean.
Root cause of m's 11:16 reminder emails: the alpine runtime image installs
only ca-certificates and ships no /usr/share/zoneinfo, so
time.LoadLocation("Europe/Berlin") errored in production. inSlot's silent
UTC fallback then matched reminder_morning_time=09:00 against now.UTC().Hour(),
firing at 09:00 UTC = 11:00 Berlin (CEST UTC+2) plus the ticker phase.
Fix:
- import _ "time/tzdata" in cmd/server/main.go (and the services package
for test parity) — embeds Go's IANA database in the binary, ~450KB,
works without OS tzdata.
- inSlot now logs and returns false on bad tz instead of pretending the
user lives in UTC. matchesLocalDueDate mirrors the same change.
- Tests updated: previous "Mars/Olympus falls back to UTC" expectation
flipped to "skips user", new TestTZDataEmbedded asserts
LoadLocation("Europe/Berlin") works in the test binary, new
TestInSlot_BerlinAt0900_NotAt1100 locks the headline regression
(must fire at 07:05 UTC = 09:05 Berlin, must NOT fire at 09:16 UTC =
11:16 Berlin).
Validation at the user-save boundary (UserService line 296-300, 619)
already rejected unparseable IANA names — that remains; this PR only
hardens the read path so any pre-existing corrupt rows skip rather than
silently reroute.
Build/vet/test/bun-build all green. Self-merging to main.
Reminders used to fire whenever the hourly ticker happened to scan after
a user's first eligible event — m got mail at 02:28. We now gate delivery
to a user-chosen hour-of-day in their local timezone.
* Migration 022 adds reminder_morning_time / reminder_evening_time /
reminder_timezone (defaults 09:00, 16:00, Europe/Berlin).
* New "due_today_evening" reminder kind with its own template — fires only
for due_date = today AND status = pending, in the evening slot.
* Reminder service computes user-local hour each tick and skips users
outside their slot. SQL widens to a 3-day band; in-process filter
narrows to per-user local date.
* Settings → Notifications gains time inputs and a timezone field.
* Tests: pure (inSlot, slotForKind, matchesLocalDueDate) plus a live-DB
TestReminderSlots covering morning, evening, outside-slot, and the
completed-deadline case.
The German→English rename (t-paliad-025) renamed the projects table and
ReminderService struct fields, but the SQL aliases in sendPerFrist /
sendWeekly still spelled `frist_title`, `akte_aktenzeichen`, and
`akte_title`. sqlx.SelectContext could not map them to the
`deadline_title` / `project_reference` / `project_title` `db:` tags, so
every hourly reminder scan returned a "missing destination name" error
and emails silently stopped going out.
This commit:
* renames struct fields AkteAktenzeichen/AkteTitle on fristReminderRow
and weeklyRow to ProjectReference/ProjectTitle and updates the `db:`
tags to project_reference / project_title.
* rewrites the SELECT aliases (deadline_title, project_reference,
project_title) to match.
* propagates the new keys through deliverFristReminder /
deliverWeekly into the email template data and renames the matching
variables in deadline_reminder.html and deadline_weekly.html.
* updates mail_service_test.go fixtures to the new keys.
* adds TestSendPerFrist_ScansCleanly — a live-DB regression test
(skips without TEST_DATABASE_URL) that seeds a project + deadline
and asserts sendPerFrist / sendWeekly scan without error, so a
future tag/alias drift fails CI instead of going silent.
Unified /einstellungen page replaces the standalone CalDAV screen. Three
tabs today (Profil / Benachrichtigungen / CalDAV); adding more is additive
(one <a> in the tab nav, one <section> panel, one loader). Tab switching
is client-side from ?tab=<name> — default tab is Profil.
Profil tab lets users fix onboarding data without admin intervention:
display name, office, role, Dezernat, language. Email is read-only (the
source of truth is auth.users and an account-level change is out of
scope for the settings page).
Benachrichtigungen tab exposes deadline reminder preferences as a master
toggle plus three per-kind sub-toggles (overdue / tomorrow / weekly).
Preferences land in paliad.users.email_preferences (JSONB); missing keys
are treated as opt-in so existing users keep the behaviour they had
before the page shipped.
CalDAV tab is the old /einstellungen/caldav screen ported inline.
/einstellungen/caldav now 301-redirects to /einstellungen?tab=caldav so
bookmarks keep working.
Backend:
- PATCH /api/me (handlers/users.go) mutates the caller's paliad.users
row. Attempts to include "email" in the body return 400 — the field is
always server-authoritative.
- UserService.UpdateProfile builds a dynamic UPDATE from the pointer
fields supplied; omitted keys are left untouched. Re-uses the
admin-bootstrap guard for role changes.
- GetByID SELECT now includes lang + email_preferences so /api/me
returns the data the settings page needs without a second round-trip.
- ReminderService consults email_preferences before sending — the helper
reminderEnabled covers the master switch and per-kind overrides; corrupt
JSON falls back to on so a bad row can't silence reminders.
- Migration 017 adds email_preferences jsonb NOT NULL DEFAULT '{}' and
upgrades lang from nullable (from 016) to NOT NULL DEFAULT 'de' with a
one-shot backfill. Down restores the nullable lang and drops
email_preferences.
Model change: User.Lang moved from *string to string — it's NOT NULL in
the DB now, so the indirection was carrying no information. Inviter.Lang
and reminder row structs followed suit; the templates and callers used
""/"en" comparisons that translate 1:1.
Sidebar: the "Einstellungen" group now links to /einstellungen (instead
of just /einstellungen/caldav); the CalDAV sub-item is folded into the
tab nav on the page itself.
Tests: reminderEnabled has table-driven coverage (master switch,
per-kind, corrupt JSON, non-bool values). DB-backed user tests still
skip without TEST_DATABASE_URL as before.
Verified: go build ./..., go vet ./..., go test ./..., bun run build —
all clean.
- internal/services/mail_service.go: SMTP/TLS sender (implicit TLS on 465),
html/template rendering, branded base layout + content templates, silent
no-op when SMTP_* unset.
- internal/services/reminder_service.go: hourly scanner for Fristen that are
overdue / due tomorrow / due within the week (Monday digest). Dedup via
paliad.reminder_log (24h window).
- internal/services/invite_service.go: POST /api/invite flow with domain
whitelist, in-memory 10/day/user rate limit, audit row in
paliad.invitations.
- internal/handlers/invite.go: POST + GET /api/invite handlers.
- Sidebar "Kolleg:in einladen" button + modal on every page.
- migration 016: paliad.reminder_log, paliad.invitations, users.lang column.
- docker-compose: SMTP_* + PALIAD_BASE_URL env vars.
- docs/feature-roadmap.md: documented Supabase auth-SMTP routing as open
question; current pilot keeps identity mails on Supabase default sender.
Rationale: get Paliad off Supabase's best-effort outbound for the
inbox-facing stuff (reminders, invitations) and move deadline nudges from
passive dashboard to active email. Custom Supabase auth SMTP is blocked on
the shared ydb.youpc.org instance — deferred until Paliad has its own
project or GoTrue webhook relay.