fix(reminder): positional placeholders, drop sqlx.Named (collides with ::cast)

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.
This commit is contained in:
m
2026-04-29 16:32:03 +02:00
parent 1652436f1b
commit 25a44dcaee

View File

@@ -225,16 +225,23 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
offset = 7
}
// Build the date predicate per slot:
// 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.
// $1 = today
// $2 = offset (days)
// $3 = userid
// $4 = is_global_admin
// 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 < :today_arg OR f.due_date = :today_arg)`
dateCond = `(f.due_date < $1 OR f.due_date = $1)`
} else {
dateCond = `(f.due_date < :today_arg
OR f.due_date = :today_arg
OR f.due_date = (:today_arg::date + (:offset_arg || ' days')::interval)::date)`
dateCond = `(f.due_date < $1
OR f.due_date = $1
OR f.due_date = ($1::date + ($2 || ' days')::interval)::date)`
}
// Audience predicates:
@@ -255,7 +262,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 = :userid_arg
WHERE pt.user_id = $3
AND pt.role = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
) AS is_lead
@@ -265,33 +272,20 @@ func (s *ReminderService) fetchSlotDeadlines(ctx context.Context, u models.User,
WHERE f.status = 'pending'
AND ` + dateCond + `
AND (
f.created_by = :userid_arg
f.created_by = $3
OR EXISTS (
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = :userid_arg
WHERE pt.user_id = $3
AND pt.role = 'lead'
AND pt.project_id = ANY(string_to_array(p.path, '.')::uuid[])
)
OR own.escalation_contact_id = :userid_arg
OR (:is_global_admin_arg = TRUE AND own.escalation_contact_id IS NULL)
OR own.escalation_contact_id = $3
OR ($4 = TRUE AND own.escalation_contact_id IS NULL)
)
ORDER BY f.due_date ASC, f.id ASC`
args := map[string]any{
"today_arg": today,
"offset_arg": offset,
"userid_arg": u.ID,
"is_global_admin_arg": isGlobalAdmin,
}
stmt, qargs, err := sqlx.Named(query, args)
if err != nil {
return nil, fmt.Errorf("named bind: %w", err)
}
stmt = s.db.Rebind(stmt)
rows := []digestRow{}
if err := s.db.SelectContext(ctx, &rows, stmt, qargs...); err != nil {
if err := s.db.SelectContext(ctx, &rows, query, today, offset, u.ID, isGlobalAdmin); err != nil {
return nil, fmt.Errorf("select deadlines: %w", err)
}