210 lines
4.7 KiB
Go
210 lines
4.7 KiB
Go
// ©AngelaMos | 2026
|
|
// repository.go
|
|
|
|
package event
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
)
|
|
|
|
var ErrNotFound = errors.New("event not found")
|
|
|
|
const defaultListLimit = 20
|
|
|
|
type Repository struct {
|
|
db *sqlx.DB
|
|
}
|
|
|
|
func NewRepository(db *sqlx.DB) *Repository {
|
|
return &Repository{db: db}
|
|
}
|
|
|
|
const insertSQL = `
|
|
INSERT INTO events (
|
|
token_id, source_ip, user_agent, referer,
|
|
geo_country, geo_region, geo_city, geo_asn, geo_asn_org,
|
|
extra, notify_status
|
|
) VALUES (
|
|
:token_id, :source_ip, :user_agent, :referer,
|
|
:geo_country, :geo_region, :geo_city, :geo_asn, :geo_asn_org,
|
|
:extra, :notify_status
|
|
)
|
|
RETURNING id, triggered_at`
|
|
|
|
func (r *Repository) Insert(ctx context.Context, e *Event) error {
|
|
if e.NotifyStatus == "" {
|
|
e.NotifyStatus = NotifyPending
|
|
}
|
|
if len(e.Extra) == 0 {
|
|
e.Extra = json.RawMessage(`{}`)
|
|
}
|
|
|
|
stmt, err := r.db.PrepareNamedContext(ctx, insertSQL)
|
|
if err != nil {
|
|
return fmt.Errorf("prepare insert event: %w", err)
|
|
}
|
|
defer stmt.Close()
|
|
|
|
if err := stmt.GetContext(ctx, e, e); err != nil {
|
|
return fmt.Errorf("insert event: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
const selectColumns = `
|
|
id, token_id, triggered_at,
|
|
source_ip, user_agent, referer,
|
|
geo_country, geo_region, geo_city, geo_asn, geo_asn_org,
|
|
extra, notify_status, notified_at`
|
|
|
|
type ListOptions struct {
|
|
Cursor int64
|
|
Limit int
|
|
}
|
|
|
|
type ListResult struct {
|
|
Events []Event
|
|
NextCursor int64
|
|
HasMore bool
|
|
}
|
|
|
|
func (r *Repository) ListByToken(
|
|
ctx context.Context, tokenID string, opts ListOptions,
|
|
) (ListResult, error) {
|
|
if opts.Limit <= 0 {
|
|
opts.Limit = defaultListLimit
|
|
}
|
|
|
|
q := `SELECT ` + selectColumns + `
|
|
FROM events
|
|
WHERE token_id = $1
|
|
AND ($2 = 0 OR id < $2)
|
|
ORDER BY id DESC
|
|
LIMIT $3`
|
|
|
|
var events []Event
|
|
err := r.db.SelectContext(ctx, &events, q, tokenID, opts.Cursor, opts.Limit+1)
|
|
if err != nil {
|
|
return ListResult{}, fmt.Errorf("list events: %w", err)
|
|
}
|
|
|
|
hasMore := len(events) > opts.Limit
|
|
if hasMore {
|
|
events = events[:opts.Limit]
|
|
}
|
|
|
|
var next int64
|
|
if hasMore && len(events) > 0 {
|
|
next = events[len(events)-1].ID
|
|
}
|
|
|
|
return ListResult{
|
|
Events: events,
|
|
NextCursor: next,
|
|
HasMore: hasMore,
|
|
}, nil
|
|
}
|
|
|
|
func (r *Repository) CountByToken(ctx context.Context, tokenID string) (int64, error) {
|
|
var n int64
|
|
err := r.db.GetContext(ctx, &n,
|
|
`SELECT COUNT(*) FROM events WHERE token_id = $1`, tokenID)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("count events: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (r *Repository) AttachFingerprint(
|
|
ctx context.Context, tokenID, sourceIP string, fingerprint json.RawMessage, window time.Duration,
|
|
) error {
|
|
q := `
|
|
UPDATE events
|
|
SET extra = extra || $3::jsonb
|
|
WHERE id = (
|
|
SELECT id FROM events
|
|
WHERE token_id = $1
|
|
AND source_ip = $2::inet
|
|
AND triggered_at >= NOW() - $4::interval
|
|
ORDER BY id DESC
|
|
LIMIT 1
|
|
)`
|
|
res, err := r.db.ExecContext(ctx, q,
|
|
tokenID, sourceIP, []byte(fingerprint), fmt.Sprintf("%d milliseconds", window.Milliseconds()))
|
|
if err != nil {
|
|
return fmt.Errorf("attach fingerprint: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("rows affected: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) UpdateNotifyStatus(
|
|
ctx context.Context, eventID int64, status NotifyStatus, sentAt *time.Time,
|
|
) error {
|
|
q := `
|
|
UPDATE events
|
|
SET notify_status = $2, notified_at = $3
|
|
WHERE id = $1`
|
|
res, err := r.db.ExecContext(ctx, q, eventID, status, sentAt)
|
|
if err != nil {
|
|
return fmt.Errorf("update notify status: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("rows affected: %w", err)
|
|
}
|
|
if n == 0 {
|
|
return ErrNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) PruneToLimit(ctx context.Context, perTokenLimit int) (int64, error) {
|
|
if perTokenLimit <= 0 {
|
|
return 0, errors.New("perTokenLimit must be positive")
|
|
}
|
|
q := `
|
|
WITH ranked AS (
|
|
SELECT id,
|
|
row_number() OVER (PARTITION BY token_id ORDER BY triggered_at DESC) AS rn
|
|
FROM events
|
|
)
|
|
DELETE FROM events
|
|
WHERE id IN (SELECT id FROM ranked WHERE rn > $1)`
|
|
res, err := r.db.ExecContext(ctx, q, perTokenLimit)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("prune events: %w", err)
|
|
}
|
|
n, err := res.RowsAffected()
|
|
if err != nil {
|
|
return 0, fmt.Errorf("rows affected: %w", err)
|
|
}
|
|
return n, nil
|
|
}
|
|
|
|
func (r *Repository) GetByID(ctx context.Context, id int64) (*Event, error) {
|
|
var e Event
|
|
q := `SELECT ` + selectColumns + ` FROM events WHERE id = $1`
|
|
err := r.db.GetContext(ctx, &e, q, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get event by id: %w", err)
|
|
}
|
|
return &e, nil
|
|
}
|