From 0e9b4637b08d3c482c255772ce4e5508f6b47079 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Sat, 4 Apr 2026 00:34:45 +0300 Subject: [PATCH] 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 --- backend/internal/db/postgres/mime_repo.go | 97 +++++++++ backend/internal/db/postgres/session_repo.go | 162 +++++++++++++++ backend/internal/db/postgres/user_repo.go | 196 +++++++++++++++++++ backend/migrations/002_core_tables.sql | 1 + backend/migrations/005_activity_tables.sql | 1 + 5 files changed, 457 insertions(+) create mode 100644 backend/internal/db/postgres/mime_repo.go create mode 100644 backend/internal/db/postgres/session_repo.go create mode 100644 backend/internal/db/postgres/user_repo.go diff --git a/backend/internal/db/postgres/mime_repo.go b/backend/internal/db/postgres/mime_repo.go new file mode 100644 index 0000000..2547257 --- /dev/null +++ b/backend/internal/db/postgres/mime_repo.go @@ -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 +} diff --git a/backend/internal/db/postgres/session_repo.go b/backend/internal/db/postgres/session_repo.go new file mode 100644 index 0000000..a7a05de --- /dev/null +++ b/backend/internal/db/postgres/session_repo.go @@ -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 +} diff --git a/backend/internal/db/postgres/user_repo.go b/backend/internal/db/postgres/user_repo.go new file mode 100644 index 0000000..49363f0 --- /dev/null +++ b/backend/internal/db/postgres/user_repo.go @@ -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 +} diff --git a/backend/migrations/002_core_tables.sql b/backend/migrations/002_core_tables.sql index 602e6ed..2c43f06 100644 --- a/backend/migrations/002_core_tables.sql +++ b/backend/migrations/002_core_tables.sql @@ -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) ); diff --git a/backend/migrations/005_activity_tables.sql b/backend/migrations/005_activity_tables.sql index e457271..418f7e2 100644 --- a/backend/migrations/005_activity_tables.sql +++ b/backend/migrations/005_activity_tables.sql @@ -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) );