Rewrites the internal/db/queries comments to rule 32 and regenerates dbq, whose doc comments and embedded SQL carry them. The bugs behind the tiered mixes, the md5 order seed, the score bands and the round-robin arms are restated as the constraint each one enforces; counts from one library and stale cross-references are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
565 lines
16 KiB
Go
565 lines
16 KiB
Go
// Code generated by sqlc. DO NOT EDIT.
|
|
// versions:
|
|
// sqlc v1.31.1
|
|
// source: notifications.sql
|
|
|
|
package dbq
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
const countUnreadNotifications = `-- name: CountUnreadNotifications :one
|
|
SELECT count(*) FROM user_notifications
|
|
WHERE user_id = $1 AND read_at IS NULL
|
|
`
|
|
|
|
func (q *Queries) CountUnreadNotifications(ctx context.Context, userID pgtype.UUID) (int64, error) {
|
|
row := q.db.QueryRow(ctx, countUnreadNotifications, userID)
|
|
var count int64
|
|
err := row.Scan(&count)
|
|
return count, err
|
|
}
|
|
|
|
const getNotificationEmailSettings = `-- name: GetNotificationEmailSettings :one
|
|
SELECT id, summary_hour, batch_window_minutes, updated_at FROM notification_email_settings WHERE id = true
|
|
`
|
|
|
|
func (q *Queries) GetNotificationEmailSettings(ctx context.Context) (NotificationEmailSetting, error) {
|
|
row := q.db.QueryRow(ctx, getNotificationEmailSettings)
|
|
var i NotificationEmailSetting
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.SummaryHour,
|
|
&i.BatchWindowMinutes,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const insertNotification = `-- name: InsertNotification :one
|
|
|
|
INSERT INTO user_notifications (user_id, kind, payload, emailed_at)
|
|
VALUES ($1, $2, $3,
|
|
CASE WHEN $4::boolean THEN NULL ELSE now() END)
|
|
RETURNING id
|
|
`
|
|
|
|
type InsertNotificationParams struct {
|
|
UserID pgtype.UUID
|
|
Kind string
|
|
Payload []byte
|
|
EmailWanted bool
|
|
}
|
|
|
|
// Notifications inbox. Rows are written only through
|
|
// internal/notifications.Notifier, which decides recipients and honours each
|
|
// recipient's inbox preference before it reaches these.
|
|
// email_wanted is the recipient's email channel for this kind as of now. A
|
|
// row nobody wants emailed is stamped at once, so the digest never has to
|
|
// judge it and a later "email on" doesn't send an old backlog.
|
|
func (q *Queries) InsertNotification(ctx context.Context, arg InsertNotificationParams) (pgtype.UUID, error) {
|
|
row := q.db.QueryRow(ctx, insertNotification,
|
|
arg.UserID,
|
|
arg.Kind,
|
|
arg.Payload,
|
|
arg.EmailWanted,
|
|
)
|
|
var id pgtype.UUID
|
|
err := row.Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
const listAdminUserIDs = `-- name: ListAdminUserIDs :many
|
|
SELECT id FROM users WHERE is_admin = true ORDER BY created_at, id
|
|
`
|
|
|
|
func (q *Queries) ListAdminUserIDs(ctx context.Context) ([]pgtype.UUID, error) {
|
|
rows, err := q.db.Query(ctx, listAdminUserIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []pgtype.UUID
|
|
for rows.Next() {
|
|
var id pgtype.UUID
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, id)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listEmailPendingNotifications = `-- name: ListEmailPendingNotifications :many
|
|
|
|
SELECT n.id, n.user_id, n.kind, n.payload, n.created_at,
|
|
u.email::text AS email, u.username, u.display_name, u.timezone,
|
|
now()::timestamptz AS read_as_of
|
|
FROM user_notifications n
|
|
JOIN users u ON u.id = n.user_id
|
|
WHERE n.read_at IS NULL
|
|
AND n.emailed_at IS NULL
|
|
AND u.email IS NOT NULL AND u.email <> ''
|
|
ORDER BY n.user_id, n.created_at, n.id
|
|
`
|
|
|
|
type ListEmailPendingNotificationsRow struct {
|
|
ID pgtype.UUID
|
|
UserID pgtype.UUID
|
|
Kind string
|
|
Payload []byte
|
|
CreatedAt pgtype.Timestamptz
|
|
Email string
|
|
Username string
|
|
DisplayName *string
|
|
Timezone string
|
|
ReadAsOf pgtype.Timestamptz
|
|
}
|
|
|
|
// Email digest ------------------------------------------------------
|
|
// Every unread, un-emailed row of a user who has an address, oldest first.
|
|
// Read rows are never selected: the user has seen them, so they are not news.
|
|
// read_as_of is the database's clock at the read, handed back to
|
|
// MarkNotificationsEmailed.
|
|
func (q *Queries) ListEmailPendingNotifications(ctx context.Context) ([]ListEmailPendingNotificationsRow, error) {
|
|
rows, err := q.db.Query(ctx, listEmailPendingNotifications)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListEmailPendingNotificationsRow
|
|
for rows.Next() {
|
|
var i ListEmailPendingNotificationsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.UserID,
|
|
&i.Kind,
|
|
&i.Payload,
|
|
&i.CreatedAt,
|
|
&i.Email,
|
|
&i.Username,
|
|
&i.DisplayName,
|
|
&i.Timezone,
|
|
&i.ReadAsOf,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listNotificationEmailState = `-- name: ListNotificationEmailState :many
|
|
SELECT user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after
|
|
FROM user_notification_email_state
|
|
`
|
|
|
|
func (q *Queries) ListNotificationEmailState(ctx context.Context) ([]UserNotificationEmailState, error) {
|
|
rows, err := q.db.Query(ctx, listNotificationEmailState)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []UserNotificationEmailState
|
|
for rows.Next() {
|
|
var i UserNotificationEmailState
|
|
if err := rows.Scan(
|
|
&i.UserID,
|
|
&i.EmailGroup,
|
|
&i.BatchOpenedAt,
|
|
&i.LastSentAt,
|
|
&i.Failures,
|
|
&i.RetryAfter,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listNotificationPrefsForKind = `-- name: ListNotificationPrefsForKind :many
|
|
SELECT user_id, inbox, phone, email
|
|
FROM user_notification_prefs
|
|
WHERE kind = $1 AND user_id = ANY($2::uuid[])
|
|
`
|
|
|
|
type ListNotificationPrefsForKindParams struct {
|
|
Kind string
|
|
UserIds []pgtype.UUID
|
|
}
|
|
|
|
type ListNotificationPrefsForKindRow struct {
|
|
UserID pgtype.UUID
|
|
Inbox bool
|
|
Phone bool
|
|
Email bool
|
|
}
|
|
|
|
// The stored prefs of one kind for a set of recipients. A recipient with no
|
|
// row has the kind's defaults.
|
|
func (q *Queries) ListNotificationPrefsForKind(ctx context.Context, arg ListNotificationPrefsForKindParams) ([]ListNotificationPrefsForKindRow, error) {
|
|
rows, err := q.db.Query(ctx, listNotificationPrefsForKind, arg.Kind, arg.UserIds)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListNotificationPrefsForKindRow
|
|
for rows.Next() {
|
|
var i ListNotificationPrefsForKindRow
|
|
if err := rows.Scan(
|
|
&i.UserID,
|
|
&i.Inbox,
|
|
&i.Phone,
|
|
&i.Email,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listNotificationPrefsForUser = `-- name: ListNotificationPrefsForUser :many
|
|
SELECT kind, inbox, phone, email
|
|
FROM user_notification_prefs
|
|
WHERE user_id = $1
|
|
`
|
|
|
|
type ListNotificationPrefsForUserRow struct {
|
|
Kind string
|
|
Inbox bool
|
|
Phone bool
|
|
Email bool
|
|
}
|
|
|
|
func (q *Queries) ListNotificationPrefsForUser(ctx context.Context, userID pgtype.UUID) ([]ListNotificationPrefsForUserRow, error) {
|
|
rows, err := q.db.Query(ctx, listNotificationPrefsForUser, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListNotificationPrefsForUserRow
|
|
for rows.Next() {
|
|
var i ListNotificationPrefsForUserRow
|
|
if err := rows.Scan(
|
|
&i.Kind,
|
|
&i.Inbox,
|
|
&i.Phone,
|
|
&i.Email,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const listNotifications = `-- name: ListNotifications :many
|
|
SELECT id, kind, payload, created_at, read_at
|
|
FROM user_notifications
|
|
WHERE user_id = $1
|
|
AND ($2::timestamptz IS NULL
|
|
OR (created_at, id) < ($2::timestamptz, $3::uuid))
|
|
ORDER BY created_at DESC, id DESC
|
|
LIMIT $4
|
|
`
|
|
|
|
type ListNotificationsParams struct {
|
|
UserID pgtype.UUID
|
|
BeforeCreatedAt pgtype.Timestamptz
|
|
BeforeID pgtype.UUID
|
|
PageLimit int32
|
|
}
|
|
|
|
type ListNotificationsRow struct {
|
|
ID pgtype.UUID
|
|
Kind string
|
|
Payload []byte
|
|
CreatedAt pgtype.Timestamptz
|
|
ReadAt pgtype.Timestamptz
|
|
}
|
|
|
|
// Newest first, keyset-paged on (created_at, id). Pass both cursor halves
|
|
// from the last row of the previous page, or neither for the first page.
|
|
func (q *Queries) ListNotifications(ctx context.Context, arg ListNotificationsParams) ([]ListNotificationsRow, error) {
|
|
rows, err := q.db.Query(ctx, listNotifications,
|
|
arg.UserID,
|
|
arg.BeforeCreatedAt,
|
|
arg.BeforeID,
|
|
arg.PageLimit,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
var items []ListNotificationsRow
|
|
for rows.Next() {
|
|
var i ListNotificationsRow
|
|
if err := rows.Scan(
|
|
&i.ID,
|
|
&i.Kind,
|
|
&i.Payload,
|
|
&i.CreatedAt,
|
|
&i.ReadAt,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
items = append(items, i)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
const markAllNotificationsRead = `-- name: MarkAllNotificationsRead :execrows
|
|
UPDATE user_notifications
|
|
SET read_at = now()
|
|
WHERE user_id = $1
|
|
AND read_at IS NULL
|
|
AND ($2::timestamptz IS NULL OR created_at <= $2)
|
|
`
|
|
|
|
type MarkAllNotificationsReadParams struct {
|
|
UserID pgtype.UUID
|
|
UpTo pgtype.Timestamptz
|
|
}
|
|
|
|
// up_to, when set, limits it to what existed when the user asked: a "mark
|
|
// all read" queued offline and replayed later must not mark notices that
|
|
// arrived in between, which the user never saw. A coalesced row that grew
|
|
// since then is marked read with it: it arrived before they asked, and its
|
|
// growth is a quiet update to a notice they had seen.
|
|
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const markNotificationRead = `-- name: MarkNotificationRead :execrows
|
|
UPDATE user_notifications
|
|
SET read_at = COALESCE(read_at, now())
|
|
WHERE id = $1 AND user_id = $2
|
|
`
|
|
|
|
type MarkNotificationReadParams struct {
|
|
ID pgtype.UUID
|
|
UserID pgtype.UUID
|
|
}
|
|
|
|
// Scoped to the owner: another user's id matches no row. Marking an already
|
|
// read row keeps its original read_at and still matches, so a repeat is not
|
|
// mistaken for "not yours".
|
|
func (q *Queries) MarkNotificationRead(ctx context.Context, arg MarkNotificationReadParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, markNotificationRead, arg.ID, arg.UserID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const markNotificationsEmailed = `-- name: MarkNotificationsEmailed :execrows
|
|
UPDATE user_notifications
|
|
SET emailed_at = now()
|
|
WHERE id = ANY($1::uuid[])
|
|
AND created_at <= $2::timestamptz
|
|
AND emailed_at IS NULL
|
|
`
|
|
|
|
type MarkNotificationsEmailedParams struct {
|
|
Ids []pgtype.UUID
|
|
ReadAsOf pgtype.Timestamptz
|
|
}
|
|
|
|
// Stamps the rows an email carried, or that were judged not to need one.
|
|
// read_as_of is from ListEmailPendingNotifications: a coalesced row updated
|
|
// since that read carries a later created_at, holds newer news, and stays
|
|
// pending for the next email.
|
|
func (q *Queries) MarkNotificationsEmailed(ctx context.Context, arg MarkNotificationsEmailedParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, markNotificationsEmailed, arg.Ids, arg.ReadAsOf)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const trimNotifications = `-- name: TrimNotifications :execrows
|
|
DELETE FROM user_notifications
|
|
WHERE (read_at IS NOT NULL AND read_at < $1)
|
|
OR created_at < $2
|
|
`
|
|
|
|
type TrimNotificationsParams struct {
|
|
ReadCutoff pgtype.Timestamptz
|
|
AnyCutoff pgtype.Timestamptz
|
|
}
|
|
|
|
// Retention: read rows go after read_cutoff, and anything at all after
|
|
// any_cutoff, so an inbox nobody opens doesn't grow without bound either.
|
|
func (q *Queries) TrimNotifications(ctx context.Context, arg TrimNotificationsParams) (int64, error) {
|
|
result, err := q.db.Exec(ctx, trimNotifications, arg.ReadCutoff, arg.AnyCutoff)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return result.RowsAffected(), nil
|
|
}
|
|
|
|
const updateNotificationEmailSettings = `-- name: UpdateNotificationEmailSettings :one
|
|
UPDATE notification_email_settings
|
|
SET summary_hour = $1,
|
|
batch_window_minutes = $2,
|
|
updated_at = now()
|
|
WHERE id = true
|
|
RETURNING id, summary_hour, batch_window_minutes, updated_at
|
|
`
|
|
|
|
type UpdateNotificationEmailSettingsParams struct {
|
|
SummaryHour int32
|
|
BatchWindowMinutes int32
|
|
}
|
|
|
|
// Migration 0074's CHECKs are the backstop behind the service's validation.
|
|
func (q *Queries) UpdateNotificationEmailSettings(ctx context.Context, arg UpdateNotificationEmailSettingsParams) (NotificationEmailSetting, error) {
|
|
row := q.db.QueryRow(ctx, updateNotificationEmailSettings, arg.SummaryHour, arg.BatchWindowMinutes)
|
|
var i NotificationEmailSetting
|
|
err := row.Scan(
|
|
&i.ID,
|
|
&i.SummaryHour,
|
|
&i.BatchWindowMinutes,
|
|
&i.UpdatedAt,
|
|
)
|
|
return i, err
|
|
}
|
|
|
|
const upsertCoalescedNotification = `-- name: UpsertCoalescedNotification :one
|
|
INSERT INTO user_notifications (user_id, kind, payload, coalesce_key, emailed_at)
|
|
VALUES ($1, $2, $3, $4,
|
|
CASE WHEN $5::boolean THEN NULL ELSE now() END)
|
|
ON CONFLICT (user_id, coalesce_key) WHERE read_at IS NULL AND coalesce_key IS NOT NULL
|
|
DO UPDATE SET
|
|
payload = EXCLUDED.payload || jsonb_build_object(
|
|
'count', COALESCE((user_notifications.payload->>'count')::bigint, 0)
|
|
+ COALESCE((EXCLUDED.payload->>'count')::bigint, 0),
|
|
'items', (SELECT COALESCE(jsonb_agg(e.item ORDER BY e.n), '[]'::jsonb)
|
|
FROM jsonb_array_elements(
|
|
COALESCE(EXCLUDED.payload->'items', '[]'::jsonb)
|
|
|| COALESCE(user_notifications.payload->'items', '[]'::jsonb)
|
|
) WITH ORDINALITY AS e(item, n)
|
|
WHERE e.n <= 10))
|
|
RETURNING id
|
|
`
|
|
|
|
type UpsertCoalescedNotificationParams struct {
|
|
UserID pgtype.UUID
|
|
Kind string
|
|
Payload []byte
|
|
CoalesceKey *string
|
|
EmailWanted bool
|
|
}
|
|
|
|
// One unread row per (user, coalesce_key). A new event while that row is
|
|
// unread updates it in place: its `count` adds to the row's, and its `items`
|
|
// (the names of what changed) go in front of the row's, keeping the newest
|
|
// ten. Every coalesced kind's event is "N more happened", never a restated
|
|
// total, so the row always says what is new since it was last read.
|
|
//
|
|
// created_at and emailed_at are left alone: an update changes what the
|
|
// notice says, not when it arrived. Moving created_at would make the phone
|
|
// announce it again and clearing emailed_at would send it again, so a notice
|
|
// that keeps growing would alert on every update.
|
|
func (q *Queries) UpsertCoalescedNotification(ctx context.Context, arg UpsertCoalescedNotificationParams) (pgtype.UUID, error) {
|
|
row := q.db.QueryRow(ctx, upsertCoalescedNotification,
|
|
arg.UserID,
|
|
arg.Kind,
|
|
arg.Payload,
|
|
arg.CoalesceKey,
|
|
arg.EmailWanted,
|
|
)
|
|
var id pgtype.UUID
|
|
err := row.Scan(&id)
|
|
return id, err
|
|
}
|
|
|
|
const upsertNotificationEmailState = `-- name: UpsertNotificationEmailState :exec
|
|
INSERT INTO user_notification_email_state
|
|
(user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after)
|
|
VALUES ($1, $2, $3,
|
|
$4, $5, $6)
|
|
ON CONFLICT (user_id, email_group) DO UPDATE SET
|
|
batch_opened_at = EXCLUDED.batch_opened_at,
|
|
last_sent_at = EXCLUDED.last_sent_at,
|
|
failures = EXCLUDED.failures,
|
|
retry_after = EXCLUDED.retry_after
|
|
`
|
|
|
|
type UpsertNotificationEmailStateParams struct {
|
|
UserID pgtype.UUID
|
|
EmailGroup string
|
|
BatchOpenedAt pgtype.Timestamptz
|
|
LastSentAt pgtype.Timestamptz
|
|
Failures int32
|
|
RetryAfter pgtype.Timestamptz
|
|
}
|
|
|
|
func (q *Queries) UpsertNotificationEmailState(ctx context.Context, arg UpsertNotificationEmailStateParams) error {
|
|
_, err := q.db.Exec(ctx, upsertNotificationEmailState,
|
|
arg.UserID,
|
|
arg.EmailGroup,
|
|
arg.BatchOpenedAt,
|
|
arg.LastSentAt,
|
|
arg.Failures,
|
|
arg.RetryAfter,
|
|
)
|
|
return err
|
|
}
|
|
|
|
const upsertNotificationPref = `-- name: UpsertNotificationPref :exec
|
|
INSERT INTO user_notification_prefs (user_id, kind, inbox, phone, email)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (user_id, kind) DO UPDATE SET
|
|
inbox = EXCLUDED.inbox,
|
|
phone = EXCLUDED.phone,
|
|
email = EXCLUDED.email,
|
|
updated_at = now()
|
|
`
|
|
|
|
type UpsertNotificationPrefParams struct {
|
|
UserID pgtype.UUID
|
|
Kind string
|
|
Inbox bool
|
|
Phone bool
|
|
Email bool
|
|
}
|
|
|
|
func (q *Queries) UpsertNotificationPref(ctx context.Context, arg UpsertNotificationPrefParams) error {
|
|
_, err := q.db.Exec(ctx, upsertNotificationPref,
|
|
arg.UserID,
|
|
arg.Kind,
|
|
arg.Inbox,
|
|
arg.Phone,
|
|
arg.Email,
|
|
)
|
|
return err
|
|
}
|