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:
parent
2c83073903
commit
0e9b4637b0
97
backend/internal/db/postgres/mime_repo.go
Normal file
97
backend/internal/db/postgres/mime_repo.go
Normal file
@ -0,0 +1,97 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"tanabata/backend/internal/domain"
|
||||
"tanabata/backend/internal/port"
|
||||
)
|
||||
|
||||
type mimeRow struct {
|
||||
ID int16 `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Extension string `db:"extension"`
|
||||
}
|
||||
|
||||
func toMIMEType(r mimeRow) domain.MIMEType {
|
||||
return domain.MIMEType{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Extension: r.Extension,
|
||||
}
|
||||
}
|
||||
|
||||
// MimeRepo implements port.MimeRepo using PostgreSQL.
|
||||
type MimeRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewMimeRepo creates a MimeRepo backed by pool.
|
||||
func NewMimeRepo(pool *pgxpool.Pool) *MimeRepo {
|
||||
return &MimeRepo{pool: pool}
|
||||
}
|
||||
|
||||
var _ port.MimeRepo = (*MimeRepo)(nil)
|
||||
|
||||
func (r *MimeRepo) List(ctx context.Context) ([]domain.MIMEType, error) {
|
||||
const sql = `SELECT id, name, extension FROM core.mime_types ORDER BY name`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MimeRepo.List: %w", err)
|
||||
}
|
||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[mimeRow])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MimeRepo.List scan: %w", err)
|
||||
}
|
||||
|
||||
result := make([]domain.MIMEType, len(collected))
|
||||
for i, row := range collected {
|
||||
result[i] = toMIMEType(row)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *MimeRepo) GetByID(ctx context.Context, id int16) (*domain.MIMEType, error) {
|
||||
const sql = `SELECT id, name, extension FROM core.mime_types WHERE id = $1`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MimeRepo.GetByID: %w", err)
|
||||
}
|
||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("MimeRepo.GetByID scan: %w", err)
|
||||
}
|
||||
m := toMIMEType(row)
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
func (r *MimeRepo) GetByName(ctx context.Context, name string) (*domain.MIMEType, error) {
|
||||
const sql = `SELECT id, name, extension FROM core.mime_types WHERE name = $1`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("MimeRepo.GetByName: %w", err)
|
||||
}
|
||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[mimeRow])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrUnsupportedMIME
|
||||
}
|
||||
return nil, fmt.Errorf("MimeRepo.GetByName scan: %w", err)
|
||||
}
|
||||
m := toMIMEType(row)
|
||||
return &m, nil
|
||||
}
|
||||
162
backend/internal/db/postgres/session_repo.go
Normal file
162
backend/internal/db/postgres/session_repo.go
Normal file
@ -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
|
||||
}
|
||||
196
backend/internal/db/postgres/user_repo.go
Normal file
196
backend/internal/db/postgres/user_repo.go
Normal file
@ -0,0 +1,196 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"tanabata/backend/internal/db"
|
||||
"tanabata/backend/internal/domain"
|
||||
"tanabata/backend/internal/port"
|
||||
)
|
||||
|
||||
// userRow matches the columns returned by every user SELECT.
|
||||
type userRow struct {
|
||||
ID int16 `db:"id"`
|
||||
Name string `db:"name"`
|
||||
Password string `db:"password"`
|
||||
IsAdmin bool `db:"is_admin"`
|
||||
CanCreate bool `db:"can_create"`
|
||||
IsBlocked bool `db:"is_blocked"`
|
||||
}
|
||||
|
||||
// userRowWithTotal extends userRow with a window-function total for List.
|
||||
type userRowWithTotal struct {
|
||||
userRow
|
||||
Total int `db:"total"`
|
||||
}
|
||||
|
||||
func toUser(r userRow) domain.User {
|
||||
return domain.User{
|
||||
ID: r.ID,
|
||||
Name: r.Name,
|
||||
Password: r.Password,
|
||||
IsAdmin: r.IsAdmin,
|
||||
CanCreate: r.CanCreate,
|
||||
IsBlocked: r.IsBlocked,
|
||||
}
|
||||
}
|
||||
|
||||
// userSortColumn whitelists valid sort keys to prevent SQL injection.
|
||||
var userSortColumn = map[string]string{
|
||||
"name": "name",
|
||||
"id": "id",
|
||||
}
|
||||
|
||||
// UserRepo implements port.UserRepo using PostgreSQL.
|
||||
type UserRepo struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// NewUserRepo creates a UserRepo backed by pool.
|
||||
func NewUserRepo(pool *pgxpool.Pool) *UserRepo {
|
||||
return &UserRepo{pool: pool}
|
||||
}
|
||||
|
||||
var _ port.UserRepo = (*UserRepo)(nil)
|
||||
|
||||
func (r *UserRepo) List(ctx context.Context, params port.OffsetParams) (*domain.UserPage, error) {
|
||||
col, ok := userSortColumn[params.Sort]
|
||||
if !ok {
|
||||
col = "id"
|
||||
}
|
||||
ord := "ASC"
|
||||
if params.Order == "desc" {
|
||||
ord = "DESC"
|
||||
}
|
||||
limit := db.ClampLimit(params.Limit, 50, 200)
|
||||
offset := db.ClampOffset(params.Offset)
|
||||
|
||||
sql := fmt.Sprintf(`
|
||||
SELECT id, name, password, is_admin, can_create, is_blocked,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM core.users
|
||||
ORDER BY %s %s
|
||||
LIMIT $1 OFFSET $2`, col, ord)
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, limit, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UserRepo.List: %w", err)
|
||||
}
|
||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[userRowWithTotal])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UserRepo.List scan: %w", err)
|
||||
}
|
||||
|
||||
page := &domain.UserPage{Offset: offset, Limit: limit}
|
||||
if len(collected) > 0 {
|
||||
page.Total = collected[0].Total
|
||||
}
|
||||
page.Items = make([]domain.User, len(collected))
|
||||
for i, row := range collected {
|
||||
page.Items[i] = toUser(row.userRow)
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByID(ctx context.Context, id int16) (*domain.User, error) {
|
||||
const sql = `
|
||||
SELECT id, name, password, is_admin, can_create, is_blocked
|
||||
FROM core.users WHERE id = $1`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UserRepo.GetByID: %w", err)
|
||||
}
|
||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("UserRepo.GetByID scan: %w", err)
|
||||
}
|
||||
u := toUser(row)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) GetByName(ctx context.Context, name string) (*domain.User, error) {
|
||||
const sql = `
|
||||
SELECT id, name, password, is_admin, can_create, is_blocked
|
||||
FROM core.users WHERE name = $1`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UserRepo.GetByName: %w", err)
|
||||
}
|
||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("UserRepo.GetByName scan: %w", err)
|
||||
}
|
||||
u := toUser(row)
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) Create(ctx context.Context, u *domain.User) (*domain.User, error) {
|
||||
const sql = `
|
||||
INSERT INTO core.users (name, password, is_admin, can_create)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, password, is_admin, can_create, is_blocked`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, u.Name, u.Password, u.IsAdmin, u.CanCreate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UserRepo.Create: %w", err)
|
||||
}
|
||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UserRepo.Create scan: %w", err)
|
||||
}
|
||||
created := toUser(row)
|
||||
return &created, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) Update(ctx context.Context, id int16, u *domain.User) (*domain.User, error) {
|
||||
const sql = `
|
||||
UPDATE core.users
|
||||
SET name = $2, password = $3, is_admin = $4, can_create = $5
|
||||
WHERE id = $1
|
||||
RETURNING id, name, password, is_admin, can_create, is_blocked`
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, sql, id, u.Name, u.Password, u.IsAdmin, u.CanCreate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("UserRepo.Update: %w", err)
|
||||
}
|
||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[userRow])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("UserRepo.Update scan: %w", err)
|
||||
}
|
||||
updated := toUser(row)
|
||||
return &updated, nil
|
||||
}
|
||||
|
||||
func (r *UserRepo) Delete(ctx context.Context, id int16) error {
|
||||
const sql = `DELETE FROM core.users WHERE id = $1`
|
||||
q := connOrTx(ctx, r.pool)
|
||||
tag, err := q.Exec(ctx, sql, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("UserRepo.Delete: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -6,6 +6,7 @@ CREATE TABLE core.users (
|
||||
password text NOT NULL, -- bcrypt hash via pgcrypto
|
||||
is_admin boolean NOT NULL DEFAULT false,
|
||||
can_create boolean NOT NULL DEFAULT false,
|
||||
is_blocked boolean NOT NULL DEFAULT false,
|
||||
|
||||
CONSTRAINT uni__users__name UNIQUE (name)
|
||||
);
|
||||
|
||||
@ -16,6 +16,7 @@ CREATE TABLE activity.sessions (
|
||||
started_at timestamptz NOT NULL DEFAULT statement_timestamp(),
|
||||
expires_at timestamptz,
|
||||
last_activity timestamptz NOT NULL DEFAULT statement_timestamp(),
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
|
||||
CONSTRAINT uni__sessions__token_hash UNIQUE (token_hash)
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user