feat(backend): implement db helpers and postgres pool/transactor
- Add is_blocked to core.users (002_core_tables.sql) - Add is_active to activity.sessions for soft deletes (005_activity_tables.sql) - Implement UserRepo: List, GetByID, GetByName, Create, Update, Delete - Implement MimeRepo: List, GetByID, GetByName - Implement SessionRepo: Create, GetByTokenHash, ListByUser, UpdateLastActivity, Delete, DeleteByUserID - Session deletes are soft (SET is_active = false); is_active is a SQL-only filter, not mapped to the domain type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"tanabata/backend/internal/domain"
|
||||
"tanabata/backend/internal/port"
|
||||
)
|
||||
|
||||
// sessionRow matches the columns stored in activity.sessions.
|
||||
// IsCurrent is a service-layer concern and is not stored in the database.
|
||||
type sessionRow struct {
|
||||
ID int `db:"id"`
|
||||
TokenHash string `db:"token_hash"`
|
||||
UserID int16 `db:"user_id"`
|
||||
UserAgent string `db:"user_agent"`
|
||||
StartedAt time.Time `db:"started_at"`
|
||||
ExpiresAt *time.Time `db:"expires_at"`
|
||||
LastActivity time.Time `db:"last_activity"`
|
||||
}
|
||||
|
||||
// sessionRowWithTotal extends sessionRow with a window-function count for ListByUser.
|
||||
type sessionRowWithTotal struct {
|
||||
sessionRow
|
||||
Total int `db:"total"`
|
||||
}
|
||||
|
||||
func toSession(r sessionRow) domain.Session {
|
||||
return domain.Session{
|
||||
ID: r.ID,
|
||||
TokenHash: r.TokenHash,
|
||||
UserID: r.UserID,
|
||||
UserAgent: r.UserAgent,
|
||||
StartedAt: r.StartedAt,
|
||||
ExpiresAt: r.ExpiresAt,
|
||||
LastActivity: r.LastActivity,
|
||||
}
|
||||
}
|
||||
|
||||
// SessionRepo implements port.SessionRepo using PostgreSQL.
|
||||
type SessionRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewSessionRepo creates a SessionRepo backed by pool.
|
||||
func NewSessionRepo(pool *pgxpool.Pool) *SessionRepo {
|
||||
return &SessionRepo{pool: pool}
|
||||
}
|
||||
|
||||
var _ port.SessionRepo = (*SessionRepo)(nil)
|
||||
|
||||
func (r *SessionRepo) Create(ctx context.Context, s *domain.Session) (*domain.Session, error) {
|
||||
const sql = `
|
||||
INSERT INTO activity.sessions (token_hash, user_id, user_agent, expires_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, token_hash, user_id, user_agent, started_at, expires_at, last_activity`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, s.TokenHash, s.UserID, s.UserAgent, s.ExpiresAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SessionRepo.Create: %w", err)
|
||||
}
|
||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SessionRepo.Create scan: %w", err)
|
||||
}
|
||||
created := toSession(row)
|
||||
return &created, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepo) GetByTokenHash(ctx context.Context, hash string) (*domain.Session, error) {
|
||||
const sql = `
|
||||
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity
|
||||
FROM activity.sessions
|
||||
WHERE token_hash = $1 AND is_active = true`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SessionRepo.GetByTokenHash: %w", err)
|
||||
}
|
||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[sessionRow])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("SessionRepo.GetByTokenHash scan: %w", err)
|
||||
}
|
||||
s := toSession(row)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepo) ListByUser(ctx context.Context, userID int16) (*domain.SessionList, error) {
|
||||
const sql = `
|
||||
SELECT id, token_hash, user_id, user_agent, started_at, expires_at, last_activity,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM activity.sessions
|
||||
WHERE user_id = $1 AND is_active = true
|
||||
ORDER BY started_at DESC`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SessionRepo.ListByUser: %w", err)
|
||||
}
|
||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[sessionRowWithTotal])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("SessionRepo.ListByUser scan: %w", err)
|
||||
}
|
||||
|
||||
list := &domain.SessionList{}
|
||||
if len(collected) > 0 {
|
||||
list.Total = collected[0].Total
|
||||
}
|
||||
list.Items = make([]domain.Session, len(collected))
|
||||
for i, row := range collected {
|
||||
list.Items[i] = toSession(row.sessionRow)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (r *SessionRepo) UpdateLastActivity(ctx context.Context, id int, t time.Time) error {
|
||||
const sql = `UPDATE activity.sessions SET last_activity = $2 WHERE id = $1`
|
||||
q := connOrTx(ctx, r.pool)
|
||||
tag, err := q.Exec(ctx, sql, id, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SessionRepo.UpdateLastActivity: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepo) Delete(ctx context.Context, id int) error {
|
||||
const sql = `UPDATE activity.sessions SET is_active = false WHERE id = $1 AND is_active = true`
|
||||
q := connOrTx(ctx, r.pool)
|
||||
tag, err := q.Exec(ctx, sql, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SessionRepo.Delete: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *SessionRepo) DeleteByUserID(ctx context.Context, userID int16) error {
|
||||
const sql = `UPDATE activity.sessions SET is_active = false WHERE user_id = $1 AND is_active = true`
|
||||
q := connOrTx(ctx, r.pool)
|
||||
_, err := q.Exec(ctx, sql, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SessionRepo.DeleteByUserID: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user