From a719eb26a64f6bc91b8f68bc1803b5ef69e0d12e Mon Sep 17 00:00:00 2001 From: m Date: Wed, 29 Apr 2026 16:34:17 +0200 Subject: [PATCH] fix(reminder): inline offset, drop unused $2 in evening query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $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. --- internal/services/reminder_service.go | 31 +++++++++++++++------------ 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/internal/services/reminder_service.go b/internal/services/reminder_service.go index 789d651..f4c6d89 100644 --- a/internal/services/reminder_service.go +++ b/internal/services/reminder_service.go @@ -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) }