fix(reminder): inline offset, drop unused $2 in evening query

$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.
This commit is contained in:
m
2026-04-29 16:34:17 +02:00
parent 25a44dcaee
commit a719eb26a6

View File

@@ -226,22 +226,25 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
}
// Build the date predicate per slot. Positional placeholders only —
// sqlx.Named cannot be used here because the query body contains
// PostgreSQL `::TYPE` cast operators (`::uuid[]`, `::date`, `::interval`)
// and sqlx eats the second `:` thinking it's a named-arg prefix.
// sqlx.Named can't be used because the query body contains PostgreSQL
// `::TYPE` cast operators and sqlx eats the second `:` as a named-arg
// prefix.
// $1 = today
// $2 = offset (days)
// $3 = userid
// $4 = is_global_admin
// $2 = userid
// $3 = is_global_admin
// `offset` is interpolated as a literal int (clamped ≥1 above) — keeping
// it as a parameter would force every slot's query to declare it even
// when unused (evening), and Postgres can't infer the type of an
// unreferenced parameter.
// morning: overdue OR due_today OR due_warning(today+offset)
// evening: overdue OR due_today (no +offset heads-up in the evening)
var dateCond string
if slot == "evening" {
dateCond = `(f.due_date < $1 OR f.due_date = $1)`
} else {
dateCond = `(f.due_date < $1
dateCond = fmt.Sprintf(`(f.due_date < $1
OR f.due_date = $1
OR f.due_date = ($1::date + ($2 || ' days')::interval)::date)`
OR f.due_date = ($1::date + '%d days'::interval)::date)`, offset)
}
// Audience predicates:
@@ -262,7 +265,7 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
p.title AS project_title,
EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $3
WHERE pt.user_id = $2
AND pt.role = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
) AS is_lead
@@ -272,20 +275,20 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
WHERE f.status = 'pending'
AND ` + dateCond + `
AND (
f.created_by = $3
f.created_by = $2
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $3
WHERE pt.user_id = $2
AND pt.role = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
)
OR own.escalation_contact_id = $3
OR ($4 = TRUE AND own.escalation_contact_id IS NULL)
OR own.escalation_contact_id = $2
OR ($3 = TRUE AND own.escalation_contact_id IS NULL)
)
ORDER BY f.due_date ASC, f.id ASC`
rows := []digestRow{}
if err := s.db.SelectContext(ctx, &rows, query, today, offset, u.ID, isGlobalAdmin); err != nil {
if err := s.db.SelectContext(ctx, &rows, query, today, u.ID, isGlobalAdmin); err != nil {
return nil, fmt.Errorf("select deadlines: %w", err)
}