release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m33s
release / android (push) Failing after 1m42s
release / integration (push) Failing after 4m42s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped
- An update to a grouped notice keeps its arrival time and email state, so the phone and the email no longer go off again each time it grows. - Notices carry the names of what changed (newest ten), shown as three and "and N more": tracks still missing after the scan's move/replacement fold, duplicate groups new to a sweep, songs merged automatically, and the track and listener a playback error was for. - Duplicates found counts only the groups new to that sweep; playback errors count reports, not the unresolved total. - A scan failure says what stopped and links to Admin > Library. - Android: a pile of more than three alerts lists the newest five titles. 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
|
|
}
|
|
|
|
// M489 notifications inbox (#726). 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 (#5346) ------------------------------------------------------
|
|
// 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 (M542).
|
|
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 (M542): an update changes what the
|
|
// notice says, not when it arrived. Moving created_at made the phone announce
|
|
// it again and clearing emailed_at sent it again, so a notice the hourly
|
|
// duplicate pass kept growing alerted every hour.
|
|
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
|
|
}
|