Merge: fix reminder_service SQL alias mismatch (t-paliad-032)

This commit is contained in:
m
2026-04-25 13:40:30 +02:00
5 changed files with 148 additions and 43 deletions

View File

@@ -44,9 +44,9 @@ func TestRenderTemplateDeadlineReminder(t *testing.T) {
"Kind": "tomorrow",
"Title": "Schriftsatz einreichen",
"DueDate": "2026-04-21",
"AkteAktenzeichen": "2026/0042",
"AkteTitle": "Mustermann ./. Musterfrau",
"DeadlineURL": "https://paliad.de/deadlines/123",
"ProjectReference": "2026/0042",
"ProjectTitle": "Mustermann ./. Musterfrau",
"DeadlineURL": "https://paliad.de/deadlines/123",
},
})
if err != nil {
@@ -110,8 +110,8 @@ func TestRenderTemplateDeadlineWeekly(t *testing.T) {
"Count": 2,
"DeadlinesURL": "https://paliad.de/deadlines",
"Items": []map[string]any{
{"DueDate": "2026-04-20", "Title": "Heute f.", "AkteAktenzeichen": "2026/0001", "URL": "https://paliad.de/deadlines/a", "Overdue": true},
{"DueDate": "2026-04-24", "Title": "Später f.", "AkteAktenzeichen": "2026/0002", "URL": "https://paliad.de/deadlines/b", "Overdue": false},
{"DueDate": "2026-04-20", "Title": "Heute f.", "ProjectReference": "2026/0001", "URL": "https://paliad.de/deadlines/a", "Overdue": true},
{"DueDate": "2026-04-24", "Title": "Später f.", "ProjectReference": "2026/0002", "URL": "https://paliad.de/deadlines/b", "Overdue": false},
},
},
})

View File

@@ -130,14 +130,14 @@ func (s *ReminderService) RunOnce(ctx context.Context) {
}
// fristReminderRow is the projection needed to render a per-Deadline email.
// We join the parent Akte for its Aktenzeichen / title and the user row for
// We join the parent Project for its reference / title and the user row for
// the preferred language and notification preferences.
type fristReminderRow struct {
DeadlineID uuid.UUID `db:"deadline_id"`
DeadlineTitle string `db:"deadline_title"`
DeadlineID uuid.UUID `db:"deadline_id"`
DeadlineTitle string `db:"deadline_title"`
DueDate time.Time `db:"due_date"`
AkteAktenzeichen string `db:"akte_aktenzeichen"`
AkteTitle string `db:"akte_title"`
ProjectReference string `db:"project_reference"`
ProjectTitle string `db:"project_title"`
UserID uuid.UUID `db:"user_id"`
UserEmail string `db:"user_email"`
UserDisplayName string `db:"user_display_name"`
@@ -169,19 +169,19 @@ func (s *ReminderService) sendPerFrist(ctx context.Context, today time.Time, kin
}
query := `
SELECT f.id AS deadline_id,
f.title AS frist_title,
f.due_date AS due_date,
COALESCE(a.reference, '') AS akte_aktenzeichen,
a.title AS akte_title,
u.id AS user_id,
u.email AS user_email,
u.display_name AS user_display_name,
u.lang AS user_lang,
u.email_preferences AS user_email_preferences
SELECT f.id AS deadline_id,
f.title AS deadline_title,
f.due_date AS due_date,
COALESCE(p.reference, '') AS project_reference,
p.title AS project_title,
u.id AS user_id,
u.email AS user_email,
u.display_name AS user_display_name,
u.lang AS user_lang,
u.email_preferences AS user_email_preferences
FROM paliad.deadlines f
JOIN paliad.projects a ON a.id = f.project_id
JOIN paliad.users u ON u.id = f.created_by
JOIN paliad.projects p ON p.id = f.project_id
JOIN paliad.users u ON u.id = f.created_by
WHERE f.status = 'pending'
AND ` + cond + `
AND NOT EXISTS (
@@ -223,9 +223,9 @@ func (s *ReminderService) deliverFristReminder(ctx context.Context, kind string,
"Kind": kind,
"Title": r.DeadlineTitle,
"DueDate": r.DueDate.Format("2006-01-02"),
"AkteAktenzeichen": r.AkteAktenzeichen,
"AkteTitle": r.AkteTitle,
"DeadlineURL": fmt.Sprintf("%s/deadlines/%s", s.baseURL, r.DeadlineID),
"ProjectReference": r.ProjectReference,
"ProjectTitle": r.ProjectTitle,
"DeadlineURL": fmt.Sprintf("%s/deadlines/%s", s.baseURL, r.DeadlineID),
}
if err := s.mail.SendTemplate(TemplateData{
To: r.UserEmail,
@@ -249,10 +249,10 @@ type weeklyRow struct {
UserLang string `db:"user_lang"`
UserEmailPreferences json.RawMessage `db:"user_email_preferences"`
DeadlineID uuid.UUID `db:"deadline_id"`
DeadlineTitle string `db:"deadline_title"`
DeadlineID uuid.UUID `db:"deadline_id"`
DeadlineTitle string `db:"deadline_title"`
DueDate time.Time `db:"due_date"`
AkteAktenzeichen string `db:"akte_aktenzeichen"`
ProjectReference string `db:"project_reference"`
}
// reminderEnabled reports whether the user's email_preferences allow a given
@@ -288,18 +288,18 @@ func (s *ReminderService) sendWeekly(ctx context.Context, today time.Time) error
end := today.AddDate(0, 0, 7)
query := `
SELECT u.id AS user_id,
u.email AS user_email,
u.display_name AS user_display_name,
u.lang AS user_lang,
u.email_preferences AS user_email_preferences,
f.id AS deadline_id,
f.title AS frist_title,
f.due_date AS due_date,
COALESCE(a.reference, '') AS akte_aktenzeichen
SELECT u.id AS user_id,
u.email AS user_email,
u.display_name AS user_display_name,
u.lang AS user_lang,
u.email_preferences AS user_email_preferences,
f.id AS deadline_id,
f.title AS deadline_title,
f.due_date AS due_date,
COALESCE(p.reference, '') AS project_reference
FROM paliad.deadlines f
JOIN paliad.projects a ON a.id = f.project_id
JOIN paliad.users u ON u.id = f.created_by
JOIN paliad.projects p ON p.id = f.project_id
JOIN paliad.users u ON u.id = f.created_by
WHERE f.status = 'pending'
AND f.due_date >= $1
AND f.due_date < $2
@@ -371,7 +371,7 @@ func (s *ReminderService) deliverWeekly(ctx context.Context, today time.Time, ro
items = append(items, map[string]any{
"DueDate": r.DueDate.Format("2006-01-02"),
"Title": r.DeadlineTitle,
"AkteAktenzeichen": r.AkteAktenzeichen,
"ProjectReference": r.ProjectReference,
"URL": fmt.Sprintf("%s/deadlines/%s", s.baseURL, r.DeadlineID),
"Overdue": r.DueDate.Before(today),
})

View File

@@ -1,8 +1,17 @@
package services
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/patholo/internal/db"
)
func TestReminderEnabled(t *testing.T) {
@@ -48,3 +57,99 @@ func TestReminderEnabled(t *testing.T) {
})
}
}
// TestSendPerFrist_ScansCleanly is the regression guard for the German→English
// rename: the prior `frist_title` / `akte_aktenzeichen` / `akte_title` SQL
// aliases didn't match the renamed struct tags, so sqlx.SelectContext returned
// "missing destination name" errors and every reminder scan silently failed.
//
// This test seeds one project + one due-today deadline owned by a user, then
// calls sendPerFrist with the overdue kind. A scan error would surface as a
// non-nil return; success means every aliased column maps to a struct field.
//
// Skips when TEST_DATABASE_URL is unset, mirroring the rest of the live-DB
// suite.
func TestSendPerFrist_ScansCleanly(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
today := time.Date(2026, 4, 25, 0, 0, 0, 0, time.UTC)
userID := uuid.New()
projectID := uuid.New()
deadlineID := uuid.New()
// Cleanup before and after — leave no rows behind on shared dev DBs.
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.reminder_log WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
// auth.users → paliad.users (the JOIN on f.created_by = u.id needs both).
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
userID, "scan-test@hlc.com"); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, role, lang, email_preferences)
VALUES ($1, $2, $3, 'munich', 'associate', 'de', '{}'::jsonb)`,
userID, "scan-test@hlc.com", "Scan Test"); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Root project (path is rewritten by the BEFORE-INSERT trigger; we still
// have to pass a placeholder because the column is NOT NULL).
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by)
VALUES ($1, 'project', $1::text, 'Scan Test Project', '2026/9999', 'active', $2)`,
projectID, userID); err != nil {
t.Fatalf("seed paliad.projects: %v", err)
}
// Due-today deadline owned by the seeded user — matches both the overdue
// (due_date <= today) and tomorrow (due_date = today+1) queries depending
// on the kind passed below.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.deadlines (id, project_id, title, due_date, source, status, created_by)
VALUES ($1, $2, 'Schriftsatz einreichen', $3, 'manual', 'pending', $4)`,
deadlineID, projectID, today, userID); err != nil {
t.Fatalf("seed paliad.deadlines: %v", err)
}
// MailService with no SMTP env is a silent no-op — perfect for a scan-path
// test; we only care that the SELECT scans without error.
mail, err := NewMailService()
if err != nil {
t.Fatalf("NewMailService: %v", err)
}
svc := NewReminderService(pool, mail, nil, "https://paliad.test")
svc.clock = func() time.Time { return today.Add(12 * time.Hour) }
if err := svc.sendPerFrist(ctx, today, "overdue"); err != nil {
t.Fatalf("sendPerFrist(overdue): %v", err)
}
if err := svc.sendPerFrist(ctx, today, "tomorrow"); err != nil {
t.Fatalf("sendPerFrist(tomorrow): %v", err)
}
if err := svc.sendWeekly(ctx, today); err != nil {
t.Fatalf("sendWeekly: %v", err)
}
}

View File

@@ -27,8 +27,8 @@
</div>
<div style="color:#57534e;font-size:13px;">
{{if eq .Lang "en"}}Matter:{{else}}Akte:{{end}}
<strong style="color:#1c1917;">{{.AkteAktenzeichen}}</strong>
{{if .AkteTitle}} &mdash; {{.AkteTitle}}{{end}}
<strong style="color:#1c1917;">{{.ProjectReference}}</strong>
{{if .ProjectTitle}} &mdash; {{.ProjectTitle}}{{end}}
</div>
</td>
</tr>

View File

@@ -25,7 +25,7 @@
<td style="padding:10px 12px;font-size:13px;border-bottom:1px solid #f5f5f4;">
<a href="{{.URL}}" style="color:#1c1917;text-decoration:none;font-weight:500;">{{.Title}}</a>
</td>
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #f5f5f4;">{{.AkteAktenzeichen}}</td>
<td style="padding:10px 12px;font-size:13px;color:#57534e;border-bottom:1px solid #f5f5f4;">{{.ProjectReference}}</td>
</tr>
{{end}}
</table>