feat(notifications): persistent notification center

- notifications table (goose migration) + sqlc queries; list with unread
  count, mark-one, mark-all endpoints under /notifications.
- Deployment success/failure now writes a notification row for the owner
  (insertUserNotification — best-effort, never breaks the deploy flow).
- Shell bell reads the real persisted feed: unread badge, click-to-read,
  mark-all, 30s polling. Replaces the transient builds+audit derivation.

Verified e2e: migration applies clean, list/read/read-all round-trips.

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-09-17 17:52:37 +02:00
1 parent 08d92fb10f
commit 07ac54aa05
14 files changed
+685 -45

No files matched your search

@@ -0,0 +1,129 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: notifications.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const countUnreadNotificationsByUser = `-- name: CountUnreadNotificationsByUser :one
SELECT COUNT(*) FROM notifications WHERE user_id = $1 AND read_at IS NULL
`
func (q *Queries) CountUnreadNotificationsByUser(ctx context.Context, userID uuid.UUID) (int64, error) {
row := q.db.QueryRowContext(ctx, countUnreadNotificationsByUser, userID)
var count int64
err := row.Scan(&count)
return count, err
}
const createNotification = `-- name: CreateNotification :exec
INSERT INTO notifications (id, user_id, kind, title, body, resource_type, resource_id, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
`
type CreateNotificationParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Kind string `json:"kind"`
Title string `json:"title"`
Body sql.NullString `json:"body"`
ResourceType sql.NullString `json:"resource_type"`
ResourceID sql.NullString `json:"resource_id"`
CreatedAt sql.NullTime `json:"created_at"`
}
func (q *Queries) CreateNotification(ctx context.Context, arg CreateNotificationParams) error {
_, err := q.db.ExecContext(ctx, createNotification,
arg.ID,
arg.UserID,
arg.Kind,
arg.Title,
arg.Body,
arg.ResourceType,
arg.ResourceID,
arg.CreatedAt,
)
return err
}
const listNotificationsByUser = `-- name: ListNotificationsByUser :many
SELECT id, user_id, kind, title, body, resource_type, resource_id, read_at, created_at
FROM notifications
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2
`
type ListNotificationsByUserParams struct {
UserID uuid.UUID `json:"user_id"`
Limit int32 `json:"limit"`
}
func (q *Queries) ListNotificationsByUser(ctx context.Context, arg ListNotificationsByUserParams) ([]Notification, error) {
rows, err := q.db.QueryContext(ctx, listNotificationsByUser, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Notification{}
for rows.Next() {
var i Notification
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Kind,
&i.Title,
&i.Body,
&i.ResourceType,
&i.ResourceID,
&i.ReadAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const markAllNotificationsReadByUser = `-- name: MarkAllNotificationsReadByUser :exec
UPDATE notifications SET read_at = $2 WHERE user_id = $1 AND read_at IS NULL
`
type MarkAllNotificationsReadByUserParams struct {
UserID uuid.UUID `json:"user_id"`
ReadAt sql.NullTime `json:"read_at"`
}
func (q *Queries) MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error {
_, err := q.db.ExecContext(ctx, markAllNotificationsReadByUser, arg.UserID, arg.ReadAt)
return err
}
const markNotificationReadByIDAndUser = `-- name: MarkNotificationReadByIDAndUser :exec
UPDATE notifications SET read_at = $3 WHERE id = $1 AND user_id = $2 AND read_at IS NULL
`
type MarkNotificationReadByIDAndUserParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
ReadAt sql.NullTime `json:"read_at"`
}
func (q *Queries) MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error {
_, err := q.db.ExecContext(ctx, markNotificationReadByIDAndUser, arg.ID, arg.UserID, arg.ReadAt)
return err
}