86215f1eb1
Three connected features for the deployment-strategy demo: 1. Visitor identity cookie (v3). A new visitors table backs a first-party gb_visitor cookie: middleware on /api checks the cookie's UUID against the database on every request and regenerates + persists it when the id is missing, malformed, or unknown (fresh browser, pruned identity, or a reset database). The identity remembers the name a visitor signed with (pre-filled next time, used for unsigned posts), replaces the standalone reaction cookie for reaction de-dupe, and feeds a live "N visiting" counter (identities seen in the last 5 minutes) shown on the wall, in /api/info, the admin overview, and the presence/tick SSE events. The admin console gains Visitors/Visiting tiles and a visitors table, plus a GET /api/me endpoint and GET /api/admin/visitors. 2. guestbook reset command. Wipes entries (including soft-deleted rows), reactions and visitor identities under the schema advisory lock, with --dry-run, --hard (also reset wall settings) and --reseed flags; meant to run as a one-off Miabi Job. After a reset every outstanding cookie is invalidated, so browsers get a fresh identity on their next request. 3. cleanup learns --prune-visitors (default 30 days): the cron job now also forgets idle identities together with their reactions. Docs (README, DEMO) updated: act 8 covers reset, the visitor-identity talking point is in act 3, and the API tables list the new endpoints. Akili-Task: tsk_ptdi5ufndrzwlljk4v5g Akili-Agent: goldfish
650 lines
21 KiB
Go
650 lines
21 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/glebarez/sqlite" // pure-Go SQLite (no CGO — keeps the static build)
|
|
"github.com/google/uuid" // visitor identity ids (v4 UUIDs)
|
|
"github.com/jkaninda/logger"
|
|
"gorm.io/driver/postgres"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// Entry is a single signature on the guestbook wall.
|
|
type Entry struct {
|
|
ID uint `gorm:"primaryKey" json:"id"`
|
|
Name string `gorm:"type:varchar(60);not null" json:"name"`
|
|
Message string `gorm:"type:varchar(500);not null" json:"message"`
|
|
Pinned bool `gorm:"not null;default:false;index" json:"pinned"`
|
|
Hidden bool `gorm:"not null;default:false;index" json:"hidden"`
|
|
Replica string `gorm:"type:varchar(128)" json:"replica,omitempty"`
|
|
IP string `gorm:"type:varchar(64)" json:"-"`
|
|
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
|
UpdatedAt time.Time `json:"-"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
|
|
// Reactions carries the per-emoji tally (e.g. {"heart":3,"tada":1}); it is
|
|
// filled by list endpoints, never stored on the entries table itself.
|
|
Reactions map[string]int `gorm:"-" json:"reactions,omitempty"`
|
|
}
|
|
|
|
// Reaction is one emoji reaction to an entry. Visitors react and un-react
|
|
// without an account; the "who" is their visitor id (see Visitor), so the
|
|
// same browser cannot inflate the count.
|
|
type Reaction struct {
|
|
EntryID uint `gorm:"primaryKey;autoIncrement:false" json:"-"`
|
|
Emoji string `gorm:"primaryKey;type:varchar(16)" json:"-"`
|
|
Who string `gorm:"primaryKey;type:varchar(64)" json:"-"`
|
|
CreatedAt time.Time `json:"-"`
|
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
|
}
|
|
|
|
// Visitor is a browser the app knows about. The identity cookie carries the
|
|
// UUID; the row in this table is what makes it valid — a cookie whose id is
|
|
// not in the database is discarded and regenerated. That keeps identities
|
|
// honest across database resets and lets stale identities be pruned by the
|
|
// cleanup job.
|
|
type Visitor struct {
|
|
ID string `gorm:"primaryKey;type:varchar(36)" json:"id"`
|
|
// Name is the last name the visitor signed with; the form pre-fills it.
|
|
Name string `gorm:"type:varchar(60)" json:"name,omitempty"`
|
|
FirstSeen time.Time `json:"first_seen"`
|
|
LastSeen time.Time `gorm:"index" json:"last_seen"`
|
|
Signatures int64 `gorm:"not null;default:0" json:"signatures"`
|
|
}
|
|
|
|
// IsZero reports whether v is an unidentified visitor (no valid cookie).
|
|
func (v *Visitor) IsZero() bool { return v == nil || v.ID == "" }
|
|
|
|
// Setting is a key/value row holding app-wide state shared by all replicas.
|
|
type Setting struct {
|
|
Key string `gorm:"primaryKey;type:varchar(64)"`
|
|
Value string `gorm:"type:text;not null"`
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// Settings is the moderator-controlled state of the wall.
|
|
type Settings struct {
|
|
SigningPaused bool `json:"signing_paused"`
|
|
Banner string `json:"banner"`
|
|
// Theme is the wall appearance forced by a moderator: "" (or "system"),
|
|
// "light" or "dark". Visitors can still override it locally.
|
|
Theme string `json:"theme"`
|
|
}
|
|
|
|
// EntryFilter selects entries for the admin console.
|
|
type EntryFilter struct {
|
|
Status string // "", "visible", "hidden" or "pinned"
|
|
Query string
|
|
Limit int
|
|
Offset int
|
|
}
|
|
|
|
// DayCount is the number of signatures created on a given UTC day.
|
|
type DayCount struct {
|
|
Day string `json:"day"`
|
|
Count int `json:"count"`
|
|
}
|
|
|
|
// Emojis a visitor can react with; also the whitelist for the API.
|
|
var reactionEmojis = []string{"heart", "tada", "thumbsup", "smile"}
|
|
|
|
// ReactionEmojis returns the whitelist, for the info endpoint.
|
|
func ReactionEmojis() []string { return reactionEmojis }
|
|
|
|
// validReaction reports whether emoji is on the whitelist.
|
|
func validReaction(emoji string) bool {
|
|
for _, e := range reactionEmojis {
|
|
if e == emoji {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Stats summarises the wall for the admin overview.
|
|
type Stats struct {
|
|
Total int64 `json:"total"`
|
|
Visible int64 `json:"visible"`
|
|
Hidden int64 `json:"hidden"`
|
|
Pinned int64 `json:"pinned"`
|
|
Today int64 `json:"today"`
|
|
Reactions int64 `json:"reactions"`
|
|
Visitors int64 `json:"visitors"`
|
|
Daily []DayCount `json:"daily"`
|
|
}
|
|
|
|
const settingsKey = "settings"
|
|
|
|
type Store struct {
|
|
db *gorm.DB
|
|
}
|
|
|
|
// NewStore opens a GORM connection using the given driver ("postgres" or
|
|
// "sqlite") and DSN. It does not block on the database being reachable — call
|
|
// Ping (e.g. from the health check) for that.
|
|
func NewStore(driver, dsn string) (*Store, error) {
|
|
var dialector gorm.Dialector
|
|
switch driver {
|
|
case "sqlite", "sqlite3":
|
|
// Create the parent directory (e.g. ./data or /data) so the SQLite
|
|
// file can be written on first run.
|
|
if err := ensureSQLiteDir(dsn); err != nil {
|
|
return nil, fmt.Errorf("create sqlite directory: %w", err)
|
|
}
|
|
dialector = sqlite.Open(dsn)
|
|
case "postgres", "":
|
|
dialector = postgres.Open(dsn)
|
|
default:
|
|
return nil, fmt.Errorf("unsupported DB_DRIVER %q (want postgres or sqlite)", driver)
|
|
}
|
|
|
|
db, err := gorm.Open(dialector, &gorm.Config{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
logger.Info("database connected", "driver", driver)
|
|
return &Store{db: db}, nil
|
|
}
|
|
|
|
// ensureSQLiteDir creates the directory that will hold the SQLite file. It is a
|
|
// no-op for in-memory databases and for a bare filename in the current dir.
|
|
func ensureSQLiteDir(dsn string) error {
|
|
// Strip a "file:" prefix and any "?query" so we get the plain file path.
|
|
path := strings.TrimPrefix(dsn, "file:")
|
|
if i := strings.IndexByte(path, '?'); i >= 0 {
|
|
path = path[:i]
|
|
}
|
|
if path == "" || strings.Contains(path, ":memory:") {
|
|
return nil
|
|
}
|
|
dir := filepath.Dir(path)
|
|
if dir == "" || dir == "." {
|
|
return nil
|
|
}
|
|
return os.MkdirAll(dir, 0o755)
|
|
}
|
|
|
|
// Close releases the underlying connection pool.
|
|
func (s *Store) Close() error {
|
|
sqlDB, err := s.db.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return sqlDB.Close()
|
|
}
|
|
|
|
// Ping verifies the database is reachable. Used by the health endpoint so
|
|
// Miabi can gate traffic until the app is actually ready.
|
|
func (s *Store) Ping(ctx context.Context) error {
|
|
sqlDB, err := s.db.DB()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return sqlDB.PingContext(ctx)
|
|
}
|
|
|
|
// Migrate applies the schema under the same lock as seeding, since
|
|
// concurrent AutoMigrate calls race on Postgres catalog inserts.
|
|
func (s *Store) Migrate() error {
|
|
return s.Exclusive(context.Background(), func(tx *Store) error {
|
|
return tx.db.AutoMigrate(&Entry{}, &Setting{}, &Reaction{}, &Visitor{})
|
|
})
|
|
}
|
|
|
|
// List returns the public wall: visible entries, pinned first, then newest.
|
|
func (s *Store) List(ctx context.Context, limit, offset int) ([]Entry, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 100
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
var entries []Entry
|
|
err := s.db.WithContext(ctx).
|
|
Where("hidden = ?", false).
|
|
Order("pinned DESC").
|
|
Order("created_at DESC").
|
|
Limit(limit).
|
|
Offset(offset).
|
|
Find(&entries).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return entries, s.loadReactions(ctx, entries)
|
|
}
|
|
|
|
// Create inserts a new entry and returns it with its generated id/timestamp.
|
|
func (s *Store) Create(ctx context.Context, name, message, replica, ip string) (Entry, error) {
|
|
e := Entry{Name: name, Message: message, Replica: replica, IP: ip}
|
|
err := s.db.WithContext(ctx).Create(&e).Error
|
|
return e, err
|
|
}
|
|
|
|
// loadReactions fills the Reactions map of every entry in one grouped query.
|
|
func (s *Store) loadReactions(ctx context.Context, entries []Entry) error {
|
|
ids := make([]uint, len(entries))
|
|
for i, e := range entries {
|
|
ids[i] = e.ID
|
|
entries[i].Reactions = map[string]int{}
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
var rows []struct {
|
|
EntryID uint
|
|
Emoji string
|
|
N int
|
|
}
|
|
if err := s.db.WithContext(ctx).Model(&Reaction{}).
|
|
Select("entry_id, emoji, count(*) AS n").
|
|
Where("entry_id IN ?", ids).
|
|
Group("entry_id, emoji").
|
|
Scan(&rows).Error; err != nil {
|
|
return err
|
|
}
|
|
byID := make(map[uint]int, len(entries))
|
|
for i, e := range entries {
|
|
byID[e.ID] = i
|
|
}
|
|
for _, r := range rows {
|
|
if i, ok := byID[r.EntryID]; ok {
|
|
entries[i].Reactions[r.Emoji] = r.N
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// React registers a reaction and returns the emoji's new total for the entry.
|
|
// It is idempotent for a given (entry, emoji, who): reacting twice is a no-op.
|
|
func (s *Store) React(ctx context.Context, entryID uint64, emoji, who string) (int, error) {
|
|
err := s.db.WithContext(ctx).
|
|
Clauses(clause.OnConflict{DoNothing: true}).
|
|
Create(&Reaction{EntryID: uint(entryID), Emoji: emoji, Who: who}).Error
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.reactionTotal(ctx, entryID, emoji)
|
|
}
|
|
|
|
// Unreact removes a reaction (idempotent) and returns the emoji's new total.
|
|
func (s *Store) Unreact(ctx context.Context, entryID uint64, emoji, who string) (int, error) {
|
|
err := s.db.WithContext(ctx).
|
|
Where("entry_id = ? AND emoji = ? AND who = ?", entryID, emoji, who).
|
|
Delete(&Reaction{}).Error
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return s.reactionTotal(ctx, entryID, emoji)
|
|
}
|
|
|
|
func (s *Store) reactionTotal(ctx context.Context, entryID uint64, emoji string) (int, error) {
|
|
var n int64
|
|
err := s.db.WithContext(ctx).Model(&Reaction{}).
|
|
Where("entry_id = ? AND emoji = ?", entryID, emoji).
|
|
Count(&n).Error
|
|
return int(n), err
|
|
}
|
|
|
|
// ReactionCounts returns the per-emoji tally for one entry.
|
|
func (s *Store) ReactionCounts(ctx context.Context, entryID uint64) (map[string]int, error) {
|
|
out := map[string]int{}
|
|
var rows []struct {
|
|
Emoji string
|
|
N int
|
|
}
|
|
err := s.db.WithContext(ctx).Model(&Reaction{}).
|
|
Select("emoji, count(*) AS n").
|
|
Where("entry_id = ?", entryID).
|
|
Group("emoji").
|
|
Scan(&rows).Error
|
|
for _, r := range rows {
|
|
out[r.Emoji] = r.N
|
|
}
|
|
return out, err
|
|
}
|
|
|
|
// ReactionTotals returns the total number of reactions across the wall.
|
|
func (s *Store) ReactionTotals(ctx context.Context) (int64, error) {
|
|
var n int64
|
|
err := s.db.WithContext(ctx).Model(&Reaction{}).Count(&n).Error
|
|
return n, err
|
|
}
|
|
|
|
// FindVisitor loads a visitor by id. Returns false when the id is unknown —
|
|
// the caller then discards the cookie and issues a fresh identity.
|
|
func (s *Store) FindVisitor(ctx context.Context, id string) (Visitor, bool, error) {
|
|
var v Visitor
|
|
err := s.db.WithContext(ctx).First(&v, "id = ?", id).Error
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return v, false, nil
|
|
}
|
|
return v, err == nil, err
|
|
}
|
|
|
|
// CreateVisitor inserts a new identity with the given UUID.
|
|
func (s *Store) CreateVisitor(ctx context.Context, id string) (Visitor, error) {
|
|
now := time.Now().UTC()
|
|
v := Visitor{ID: id, FirstSeen: now, LastSeen: now}
|
|
err := s.db.WithContext(ctx).Create(&v).Error
|
|
return v, err
|
|
}
|
|
|
|
// TouchVisitor stamps the visitor's last-seen time.
|
|
func (s *Store) TouchVisitor(ctx context.Context, id string, at time.Time) {
|
|
s.db.WithContext(ctx).Model(&Visitor{}).Where("id = ?", id).Update("last_seen", at)
|
|
}
|
|
|
|
// VisitorSigned records a new signature: it bumps the counter and remembers
|
|
// the name, so the form can be pre-filled on the next visit.
|
|
func (s *Store) VisitorSigned(ctx context.Context, id, name string) {
|
|
s.db.WithContext(ctx).Model(&Visitor{}).
|
|
Where("id = ?", id).
|
|
Updates(map[string]any{"name": name, "signatures": gorm.Expr("signatures + 1")})
|
|
}
|
|
|
|
// VisitorCount returns the number of identities the app has issued.
|
|
func (s *Store) VisitorCount(ctx context.Context) (int64, error) {
|
|
var n int64
|
|
err := s.db.WithContext(ctx).Model(&Visitor{}).Count(&n).Error
|
|
return n, err
|
|
}
|
|
|
|
// VisitorsSeenAfter counts visitors active since the given time — the
|
|
// "currently visiting" number, distinct from connected SSE clients.
|
|
func (s *Store) VisitorsSeenAfter(ctx context.Context, since time.Time) (int64, error) {
|
|
var n int64
|
|
err := s.db.WithContext(ctx).Model(&Visitor{}).Where("last_seen >= ?", since).Count(&n).Error
|
|
return n, err
|
|
}
|
|
|
|
// AdminVisitors lists the most recently active visitors.
|
|
func (s *Store) AdminVisitors(ctx context.Context, limit int) ([]Visitor, error) {
|
|
if limit <= 0 || limit > 200 {
|
|
limit = 50
|
|
}
|
|
var visitors []Visitor
|
|
err := s.db.WithContext(ctx).Order("last_seen DESC").Limit(limit).Find(&visitors).Error
|
|
return visitors, err
|
|
}
|
|
|
|
// PurgeVisitors permanently removes visitors idle since cutoff, together with
|
|
// their reactions, so a later signup with a fresh cookie starts clean. It
|
|
// returns the number of visitors removed.
|
|
func (s *Store) PurgeVisitors(ctx context.Context, cutoff time.Time, dryRun bool) (int64, error) {
|
|
scope := s.db.WithContext(ctx).Where("last_seen < ?", cutoff)
|
|
if dryRun {
|
|
var n int64
|
|
err := scope.Model(&Visitor{}).Count(&n).Error
|
|
return n, err
|
|
}
|
|
var ids []string
|
|
if err := scope.Model(&Visitor{}).Pluck("id", &ids).Error; err != nil || len(ids) == 0 {
|
|
return 0, err
|
|
}
|
|
if err := s.db.WithContext(ctx).Where("who IN ?", ids).Delete(&Reaction{}).Error; err != nil {
|
|
return 0, err
|
|
}
|
|
res := s.db.WithContext(ctx).Where("id IN ?", ids).Delete(&Visitor{})
|
|
return res.RowsAffected, res.Error
|
|
}
|
|
|
|
// ResetStats reports what Reset removed (or would remove, when DryRun).
|
|
type ResetStats struct {
|
|
Entries int64 `json:"entries"`
|
|
Reactions int64 `json:"reactions"`
|
|
Visitors int64 `json:"visitors"`
|
|
Settings int64 `json:"settings"`
|
|
DryRun bool `json:"dry_run"`
|
|
}
|
|
|
|
// Reset wipes the wall so a demo can start from scratch (typically run as a
|
|
// one-off Job, followed by a restart to re-seed). Entries and reactions
|
|
// always go; settings are kept unless hard is set. With dryRun it only
|
|
// reports what would be removed.
|
|
func (s *Store) Reset(ctx context.Context, hard, dryRun bool) (ResetStats, error) {
|
|
var out ResetStats
|
|
out.DryRun = dryRun
|
|
err := s.Exclusive(ctx, func(tx *Store) error {
|
|
count := func(model any, dst *int64, unscoped bool) error {
|
|
q := tx.db.WithContext(ctx).Model(model)
|
|
if unscoped {
|
|
q = q.Unscoped()
|
|
}
|
|
return q.Count(dst).Error
|
|
}
|
|
// Soft-deleted entries are gone for good too: after a reset there is
|
|
// nothing left to purge.
|
|
if err := count(&Entry{}, &out.Entries, true); err != nil {
|
|
return err
|
|
}
|
|
if err := count(&Reaction{}, &out.Reactions, false); err != nil {
|
|
return err
|
|
}
|
|
if err := count(&Visitor{}, &out.Visitors, false); err != nil {
|
|
return err
|
|
}
|
|
if hard {
|
|
if err := count(&Setting{}, &out.Settings, false); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if dryRun {
|
|
return nil
|
|
}
|
|
wipe := func(model any) error {
|
|
// "WHERE 1=1" satisfies GORM's global-delete guard.
|
|
return tx.db.WithContext(ctx).Unscoped().Where("1 = 1").Delete(model).Error
|
|
}
|
|
for _, model := range []any{&Entry{}, &Reaction{}, &Visitor{}} {
|
|
if err := wipe(model); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if hard {
|
|
return wipe(&Setting{})
|
|
}
|
|
return nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// NewVisitorID returns a random UUID for a fresh identity.
|
|
func NewVisitorID() string { return uuid.NewString() }
|
|
|
|
// ValidVisitorID reports whether id is a syntactically valid visitor id.
|
|
// A malformed cookie is discarded without hitting the database.
|
|
func ValidVisitorID(id string) bool {
|
|
return len(id) == 36 && uuid.Validate(id) == nil
|
|
}
|
|
|
|
// Delete soft-deletes an entry by id. Returns true when a row was affected.
|
|
func (s *Store) Delete(ctx context.Context, id uint64) (bool, error) {
|
|
res := s.db.WithContext(ctx).Delete(&Entry{}, id)
|
|
return res.RowsAffected > 0, res.Error
|
|
}
|
|
|
|
// schemaLockID is an arbitrary Postgres advisory-lock key.
|
|
const schemaLockID = 7_406_381
|
|
|
|
// Exclusive runs fn in a transaction holding a Postgres advisory lock, so
|
|
// replicas booting together migrate and seed one at a time.
|
|
func (s *Store) Exclusive(ctx context.Context, fn func(tx *Store) error) error {
|
|
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
|
if tx.Name() == "postgres" {
|
|
if err := tx.Exec("SELECT pg_advisory_xact_lock(?)", schemaLockID).Error; err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return fn(&Store{db: tx})
|
|
})
|
|
}
|
|
|
|
// Count returns the total number of (non-deleted) entries, hidden included.
|
|
func (s *Store) Count(ctx context.Context) (int64, error) {
|
|
var n int64
|
|
err := s.db.WithContext(ctx).Model(&Entry{}).Count(&n).Error
|
|
return n, err
|
|
}
|
|
|
|
// CountVisible returns the number of entries shown on the public wall.
|
|
func (s *Store) CountVisible(ctx context.Context) (int64, error) {
|
|
var n int64
|
|
err := s.db.WithContext(ctx).Model(&Entry{}).Where("hidden = ?", false).Count(&n).Error
|
|
return n, err
|
|
}
|
|
|
|
// Get returns a single entry by id, with its reaction counts.
|
|
func (s *Store) Get(ctx context.Context, id uint64) (Entry, error) {
|
|
var e Entry
|
|
err := s.db.WithContext(ctx).First(&e, id).Error
|
|
if err != nil {
|
|
return e, err
|
|
}
|
|
e.Reactions, err = s.ReactionCounts(ctx, id)
|
|
return e, err
|
|
}
|
|
|
|
// AdminList returns entries matching f, newest first, and the match count.
|
|
func (s *Store) AdminList(ctx context.Context, f EntryFilter) ([]Entry, int64, error) {
|
|
q := s.db.WithContext(ctx).Model(&Entry{})
|
|
switch f.Status {
|
|
case "visible":
|
|
q = q.Where("hidden = ?", false)
|
|
case "hidden":
|
|
q = q.Where("hidden = ?", true)
|
|
case "pinned":
|
|
q = q.Where("pinned = ?", true)
|
|
}
|
|
if term := strings.TrimSpace(f.Query); term != "" {
|
|
like := "%" + strings.ToLower(term) + "%"
|
|
q = q.Where("LOWER(name) LIKE ? OR LOWER(message) LIKE ? OR ip LIKE ?", like, like, like)
|
|
}
|
|
|
|
var total int64
|
|
if err := q.Count(&total).Error; err != nil {
|
|
return nil, 0, err
|
|
}
|
|
var entries []Entry
|
|
err := q.Order("created_at DESC").Limit(f.Limit).Offset(f.Offset).Find(&entries).Error
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return entries, total, s.loadReactions(ctx, entries)
|
|
}
|
|
|
|
// SetFlags updates the moderation flags that are non-nil and returns the entry.
|
|
func (s *Store) SetFlags(ctx context.Context, id uint64, pinned, hidden *bool) (Entry, error) {
|
|
updates := map[string]any{}
|
|
if pinned != nil {
|
|
updates["pinned"] = *pinned
|
|
}
|
|
if hidden != nil {
|
|
updates["hidden"] = *hidden
|
|
}
|
|
e, err := s.Get(ctx, id)
|
|
if err != nil || len(updates) == 0 {
|
|
return e, err
|
|
}
|
|
if err := s.db.WithContext(ctx).Model(&e).Updates(updates).Error; err != nil {
|
|
return e, err
|
|
}
|
|
return s.Get(ctx, id)
|
|
}
|
|
|
|
// All returns every entry, oldest first, for export.
|
|
func (s *Store) All(ctx context.Context) ([]Entry, error) {
|
|
var entries []Entry
|
|
err := s.db.WithContext(ctx).Order("created_at ASC").Find(&entries).Error
|
|
return entries, err
|
|
}
|
|
|
|
// Stats computes the admin overview, including per-day counts for the last
|
|
// `days` days. Bucketing happens in Go so it works the same on both drivers.
|
|
func (s *Store) Stats(ctx context.Context, days int) (Stats, error) {
|
|
var st Stats
|
|
db := s.db.WithContext(ctx).Model(&Entry{})
|
|
if err := db.Count(&st.Total).Error; err != nil {
|
|
return st, err
|
|
}
|
|
s.db.WithContext(ctx).Model(&Entry{}).Where("hidden = ?", true).Count(&st.Hidden)
|
|
s.db.WithContext(ctx).Model(&Entry{}).Where("pinned = ?", true).Count(&st.Pinned)
|
|
s.db.WithContext(ctx).Model(&Reaction{}).Count(&st.Reactions)
|
|
s.db.WithContext(ctx).Model(&Visitor{}).Count(&st.Visitors)
|
|
st.Visible = st.Total - st.Hidden
|
|
|
|
now := time.Now().UTC()
|
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
since := today.AddDate(0, 0, -(days - 1))
|
|
|
|
var stamps []time.Time
|
|
if err := s.db.WithContext(ctx).Model(&Entry{}).
|
|
Where("created_at >= ?", since).
|
|
Pluck("created_at", &stamps).Error; err != nil {
|
|
return st, err
|
|
}
|
|
buckets := make(map[string]int, days)
|
|
for _, t := range stamps {
|
|
buckets[t.UTC().Format("2006-01-02")]++
|
|
}
|
|
for d := since; !d.After(today); d = d.AddDate(0, 0, 1) {
|
|
key := d.Format("2006-01-02")
|
|
st.Daily = append(st.Daily, DayCount{Day: key, Count: buckets[key]})
|
|
}
|
|
st.Today = int64(buckets[today.Format("2006-01-02")])
|
|
return st, nil
|
|
}
|
|
|
|
// Settings loads the shared wall settings, returning defaults when unset.
|
|
func (s *Store) Settings(ctx context.Context) (Settings, error) {
|
|
var row Setting
|
|
var st Settings
|
|
err := s.db.WithContext(ctx).Where(&Setting{Key: settingsKey}).Limit(1).Find(&row).Error
|
|
if err != nil || row.Value == "" {
|
|
return st, err
|
|
}
|
|
err = json.Unmarshal([]byte(row.Value), &st)
|
|
return st, err
|
|
}
|
|
|
|
// SaveSettings persists the shared wall settings.
|
|
func (s *Store) SaveSettings(ctx context.Context, st Settings) error {
|
|
payload, err := json.Marshal(st)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.db.WithContext(ctx).Save(&Setting{Key: settingsKey, Value: string(payload)}).Error
|
|
}
|
|
|
|
// Purge permanently removes entries soft-deleted before cutoff.
|
|
func (s *Store) Purge(ctx context.Context, cutoff time.Time, dryRun bool) (int64, error) {
|
|
q := s.db.WithContext(ctx).Unscoped().Where("deleted_at IS NOT NULL AND deleted_at < ?", cutoff)
|
|
if dryRun {
|
|
var n int64
|
|
err := q.Model(&Entry{}).Count(&n).Error
|
|
return n, err
|
|
}
|
|
res := q.Delete(&Entry{})
|
|
return res.RowsAffected, res.Error
|
|
}
|
|
|
|
// Expire soft-deletes unpinned entries created before cutoff.
|
|
func (s *Store) Expire(ctx context.Context, cutoff time.Time, dryRun bool) (int64, error) {
|
|
q := s.db.WithContext(ctx).Where("pinned = ? AND created_at < ?", false, cutoff)
|
|
if dryRun {
|
|
var n int64
|
|
err := q.Model(&Entry{}).Count(&n).Error
|
|
return n, err
|
|
}
|
|
res := q.Delete(&Entry{})
|
|
return res.RowsAffected, res.Error
|
|
}
|