Compare commits
No commits in common. "3a4903650773935ea1749fe2625d84fd9e8c68e5" and "595b8fa6710c39c5a53dc835139e6ebfda3d692b" have entirely different histories.
3a49036507
...
595b8fa671
@ -52,7 +52,4 @@ npm run generate:types # regenerate API types from openapi.yaml
|
|||||||
- TypeScript: strict mode, named exports
|
- TypeScript: strict mode, named exports
|
||||||
- SQL: snake_case, all migrations via goose
|
- SQL: snake_case, all migrations via goose
|
||||||
- API errors: { code, message, details? }
|
- API errors: { code, message, details? }
|
||||||
- Git: conventional commits with scope — `type(scope): message`
|
- Git: conventional commits (feat:, fix:, docs:, refactor:)
|
||||||
- `(backend)` for Go backend code
|
|
||||||
- `(frontend)` for SvelteKit/TypeScript code
|
|
||||||
- `(project)` for root-level files (.gitignore, docs/reference, structure)
|
|
||||||
|
|||||||
@ -65,8 +65,6 @@ func main() {
|
|||||||
auditRepo := postgres.NewAuditRepo(pool)
|
auditRepo := postgres.NewAuditRepo(pool)
|
||||||
tagRepo := postgres.NewTagRepo(pool)
|
tagRepo := postgres.NewTagRepo(pool)
|
||||||
tagRuleRepo := postgres.NewTagRuleRepo(pool)
|
tagRuleRepo := postgres.NewTagRuleRepo(pool)
|
||||||
categoryRepo := postgres.NewCategoryRepo(pool)
|
|
||||||
poolRepo := postgres.NewPoolRepo(pool)
|
|
||||||
transactor := postgres.NewTransactor(pool)
|
transactor := postgres.NewTransactor(pool)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
@ -80,8 +78,6 @@ func main() {
|
|||||||
aclSvc := service.NewACLService(aclRepo)
|
aclSvc := service.NewACLService(aclRepo)
|
||||||
auditSvc := service.NewAuditService(auditRepo)
|
auditSvc := service.NewAuditService(auditRepo)
|
||||||
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
|
||||||
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
|
|
||||||
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
|
|
||||||
fileSvc := service.NewFileService(
|
fileSvc := service.NewFileService(
|
||||||
fileRepo,
|
fileRepo,
|
||||||
mimeRepo,
|
mimeRepo,
|
||||||
@ -98,10 +94,8 @@ func main() {
|
|||||||
authHandler := handler.NewAuthHandler(authSvc)
|
authHandler := handler.NewAuthHandler(authSvc)
|
||||||
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
fileHandler := handler.NewFileHandler(fileSvc, tagSvc)
|
||||||
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
|
||||||
categoryHandler := handler.NewCategoryHandler(categorySvc)
|
|
||||||
poolHandler := handler.NewPoolHandler(poolSvc)
|
|
||||||
|
|
||||||
r := handler.NewRouter(authMiddleware, authHandler, fileHandler, tagHandler, categoryHandler, poolHandler)
|
r := handler.NewRouter(authMiddleware, authHandler, fileHandler, tagHandler)
|
||||||
|
|
||||||
slog.Info("starting server", "addr", cfg.ListenAddr)
|
slog.Info("starting server", "addr", cfg.ListenAddr)
|
||||||
if err := r.Run(cfg.ListenAddr); err != nil {
|
if err := r.Run(cfg.ListenAddr); err != nil {
|
||||||
|
|||||||
@ -1,297 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Row struct
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type categoryRow struct {
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Color *string `db:"color"`
|
|
||||||
Metadata []byte `db:"metadata"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type categoryRowWithTotal struct {
|
|
||||||
categoryRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Converter
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func toCategory(r categoryRow) domain.Category {
|
|
||||||
c := domain.Category{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Notes: r.Notes,
|
|
||||||
Color: r.Color,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
}
|
|
||||||
if len(r.Metadata) > 0 && string(r.Metadata) != "null" {
|
|
||||||
c.Metadata = json.RawMessage(r.Metadata)
|
|
||||||
}
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared SQL
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const categorySelectFrom = `
|
|
||||||
SELECT
|
|
||||||
c.id,
|
|
||||||
c.name,
|
|
||||||
c.notes,
|
|
||||||
c.color,
|
|
||||||
c.metadata,
|
|
||||||
c.creator_id,
|
|
||||||
u.name AS creator_name,
|
|
||||||
c.is_public
|
|
||||||
FROM data.categories c
|
|
||||||
JOIN core.users u ON u.id = c.creator_id`
|
|
||||||
|
|
||||||
func categorySortColumn(s string) string {
|
|
||||||
if s == "name" {
|
|
||||||
return "c.name"
|
|
||||||
}
|
|
||||||
return "c.id" // "created"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CategoryRepo — implements port.CategoryRepo
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// CategoryRepo handles category CRUD using PostgreSQL.
|
|
||||||
type CategoryRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.CategoryRepo = (*CategoryRepo)(nil)
|
|
||||||
|
|
||||||
// NewCategoryRepo creates a CategoryRepo backed by pool.
|
|
||||||
func NewCategoryRepo(pool *pgxpool.Pool) *CategoryRepo {
|
|
||||||
return &CategoryRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// List
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *CategoryRepo) List(ctx context.Context, params port.OffsetParams) (*domain.CategoryOffsetPage, error) {
|
|
||||||
order := "ASC"
|
|
||||||
if strings.ToLower(params.Order) == "desc" {
|
|
||||||
order = "DESC"
|
|
||||||
}
|
|
||||||
sortCol := categorySortColumn(params.Sort)
|
|
||||||
|
|
||||||
args := []any{}
|
|
||||||
n := 1
|
|
||||||
var conditions []string
|
|
||||||
|
|
||||||
if params.Search != "" {
|
|
||||||
conditions = append(conditions, fmt.Sprintf("lower(c.name) LIKE lower($%d)", n))
|
|
||||||
args = append(args, "%"+params.Search+"%")
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
|
|
||||||
where := ""
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
where = "WHERE " + strings.Join(conditions, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := params.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
offset := params.Offset
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
SELECT
|
|
||||||
c.id, c.name, c.notes, c.color, c.metadata,
|
|
||||||
c.creator_id, u.name AS creator_name, c.is_public,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM data.categories c
|
|
||||||
JOIN core.users u ON u.id = c.creator_id
|
|
||||||
%s
|
|
||||||
ORDER BY %s %s NULLS LAST, c.id ASC
|
|
||||||
LIMIT $%d OFFSET $%d`, where, sortCol, order, n, n+1)
|
|
||||||
|
|
||||||
args = append(args, limit, offset)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.List query: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[categoryRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]domain.Category, len(collected))
|
|
||||||
total := 0
|
|
||||||
for i, row := range collected {
|
|
||||||
items[i] = toCategory(row.categoryRow)
|
|
||||||
total = row.Total
|
|
||||||
}
|
|
||||||
return &domain.CategoryOffsetPage{
|
|
||||||
Items: items,
|
|
||||||
Total: total,
|
|
||||||
Offset: offset,
|
|
||||||
Limit: limit,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GetByID
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *CategoryRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Category, error) {
|
|
||||||
const query = categorySelectFrom + `
|
|
||||||
WHERE c.id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
c := toCategory(row)
|
|
||||||
return &c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Create
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *CategoryRepo) Create(ctx context.Context, c *domain.Category) (*domain.Category, error) {
|
|
||||||
const query = `
|
|
||||||
WITH ins AS (
|
|
||||||
INSERT INTO data.categories (name, notes, color, metadata, creator_id, is_public)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT ins.id, ins.name, ins.notes, ins.color, ins.metadata,
|
|
||||||
ins.creator_id, u.name AS creator_name, ins.is_public
|
|
||||||
FROM ins
|
|
||||||
JOIN core.users u ON u.id = ins.creator_id`
|
|
||||||
|
|
||||||
var meta any
|
|
||||||
if len(c.Metadata) > 0 {
|
|
||||||
meta = c.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query,
|
|
||||||
c.Name, c.Notes, c.Color, meta, c.CreatorID, c.IsPublic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
|
|
||||||
if err != nil {
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toCategory(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Update
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Update replaces all mutable fields. The caller must merge current values
|
|
||||||
// with the patch before calling (read-then-write semantics).
|
|
||||||
func (r *CategoryRepo) Update(ctx context.Context, id uuid.UUID, c *domain.Category) (*domain.Category, error) {
|
|
||||||
const query = `
|
|
||||||
WITH upd AS (
|
|
||||||
UPDATE data.categories SET
|
|
||||||
name = $2,
|
|
||||||
notes = $3,
|
|
||||||
color = $4,
|
|
||||||
metadata = COALESCE($5, metadata),
|
|
||||||
is_public = $6
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT upd.id, upd.name, upd.notes, upd.color, upd.metadata,
|
|
||||||
upd.creator_id, u.name AS creator_name, upd.is_public
|
|
||||||
FROM upd
|
|
||||||
JOIN core.users u ON u.id = upd.creator_id`
|
|
||||||
|
|
||||||
var meta any
|
|
||||||
if len(c.Metadata) > 0 {
|
|
||||||
meta = c.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query,
|
|
||||||
id, c.Name, c.Notes, c.Color, meta, c.IsPublic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.Update: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[categoryRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("CategoryRepo.Update scan: %w", err)
|
|
||||||
}
|
|
||||||
updated := toCategory(row)
|
|
||||||
return &updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Delete
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *CategoryRepo) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
const query = `DELETE FROM data.categories WHERE id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
ct, err := q.Exec(ctx, query, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("CategoryRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if ct.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@ -1,665 +0,0 @@
|
|||||||
package postgres
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/jackc/pgx/v5"
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/db"
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Row structs
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type poolRow struct {
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
Name string `db:"name"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Metadata []byte `db:"metadata"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
FileCount int `db:"file_count"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type poolRowWithTotal struct {
|
|
||||||
poolRow
|
|
||||||
Total int `db:"total"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// poolFileRow is a flat struct combining all file columns plus pool position.
|
|
||||||
type poolFileRow struct {
|
|
||||||
ID uuid.UUID `db:"id"`
|
|
||||||
OriginalName *string `db:"original_name"`
|
|
||||||
MIMEType string `db:"mime_type"`
|
|
||||||
MIMEExtension string `db:"mime_extension"`
|
|
||||||
ContentDatetime time.Time `db:"content_datetime"`
|
|
||||||
Notes *string `db:"notes"`
|
|
||||||
Metadata json.RawMessage `db:"metadata"`
|
|
||||||
EXIF json.RawMessage `db:"exif"`
|
|
||||||
PHash *int64 `db:"phash"`
|
|
||||||
CreatorID int16 `db:"creator_id"`
|
|
||||||
CreatorName string `db:"creator_name"`
|
|
||||||
IsPublic bool `db:"is_public"`
|
|
||||||
IsDeleted bool `db:"is_deleted"`
|
|
||||||
Position int `db:"position"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Converters
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func toPool(r poolRow) domain.Pool {
|
|
||||||
p := domain.Pool{
|
|
||||||
ID: r.ID,
|
|
||||||
Name: r.Name,
|
|
||||||
Notes: r.Notes,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
FileCount: r.FileCount,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
}
|
|
||||||
if len(r.Metadata) > 0 && string(r.Metadata) != "null" {
|
|
||||||
p.Metadata = json.RawMessage(r.Metadata)
|
|
||||||
}
|
|
||||||
return p
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPoolFile(r poolFileRow) domain.PoolFile {
|
|
||||||
return domain.PoolFile{
|
|
||||||
File: domain.File{
|
|
||||||
ID: r.ID,
|
|
||||||
OriginalName: r.OriginalName,
|
|
||||||
MIMEType: r.MIMEType,
|
|
||||||
MIMEExtension: r.MIMEExtension,
|
|
||||||
ContentDatetime: r.ContentDatetime,
|
|
||||||
Notes: r.Notes,
|
|
||||||
Metadata: r.Metadata,
|
|
||||||
EXIF: r.EXIF,
|
|
||||||
PHash: r.PHash,
|
|
||||||
CreatorID: r.CreatorID,
|
|
||||||
CreatorName: r.CreatorName,
|
|
||||||
IsPublic: r.IsPublic,
|
|
||||||
IsDeleted: r.IsDeleted,
|
|
||||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
|
||||||
},
|
|
||||||
Position: r.Position,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Cursor
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type poolFileCursor struct {
|
|
||||||
Position int `json:"p"`
|
|
||||||
FileID string `json:"id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func encodePoolCursor(c poolFileCursor) string {
|
|
||||||
b, _ := json.Marshal(c)
|
|
||||||
return base64.RawURLEncoding.EncodeToString(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodePoolCursor(s string) (poolFileCursor, error) {
|
|
||||||
b, err := base64.RawURLEncoding.DecodeString(s)
|
|
||||||
if err != nil {
|
|
||||||
return poolFileCursor{}, fmt.Errorf("cursor: invalid encoding")
|
|
||||||
}
|
|
||||||
var c poolFileCursor
|
|
||||||
if err := json.Unmarshal(b, &c); err != nil {
|
|
||||||
return poolFileCursor{}, fmt.Errorf("cursor: invalid format")
|
|
||||||
}
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Shared SQL
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// poolCountSubquery computes per-pool file counts.
|
|
||||||
const poolCountSubquery = `(SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool GROUP BY pool_id)`
|
|
||||||
|
|
||||||
const poolSelectFrom = `
|
|
||||||
SELECT p.id, p.name, p.notes, p.metadata,
|
|
||||||
p.creator_id, u.name AS creator_name, p.is_public,
|
|
||||||
COALESCE(fc.cnt, 0) AS file_count
|
|
||||||
FROM data.pools p
|
|
||||||
JOIN core.users u ON u.id = p.creator_id
|
|
||||||
LEFT JOIN ` + poolCountSubquery + ` fc ON fc.pool_id = p.id`
|
|
||||||
|
|
||||||
func poolSortColumn(s string) string {
|
|
||||||
if s == "name" {
|
|
||||||
return "p.name"
|
|
||||||
}
|
|
||||||
return "p.id" // "created"
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PoolRepo
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// PoolRepo implements port.PoolRepo using PostgreSQL.
|
|
||||||
type PoolRepo struct {
|
|
||||||
pool *pgxpool.Pool
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ port.PoolRepo = (*PoolRepo)(nil)
|
|
||||||
|
|
||||||
// NewPoolRepo creates a PoolRepo backed by pool.
|
|
||||||
func NewPoolRepo(pool *pgxpool.Pool) *PoolRepo {
|
|
||||||
return &PoolRepo{pool: pool}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// List
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain.PoolOffsetPage, error) {
|
|
||||||
order := "ASC"
|
|
||||||
if strings.ToLower(params.Order) == "desc" {
|
|
||||||
order = "DESC"
|
|
||||||
}
|
|
||||||
sortCol := poolSortColumn(params.Sort)
|
|
||||||
|
|
||||||
args := []any{}
|
|
||||||
n := 1
|
|
||||||
var conditions []string
|
|
||||||
|
|
||||||
if params.Search != "" {
|
|
||||||
conditions = append(conditions, fmt.Sprintf("lower(p.name) LIKE lower($%d)", n))
|
|
||||||
args = append(args, "%"+params.Search+"%")
|
|
||||||
n++
|
|
||||||
}
|
|
||||||
|
|
||||||
where := ""
|
|
||||||
if len(conditions) > 0 {
|
|
||||||
where = "WHERE " + strings.Join(conditions, " AND ")
|
|
||||||
}
|
|
||||||
|
|
||||||
limit := params.Limit
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 50
|
|
||||||
}
|
|
||||||
offset := params.Offset
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
query := fmt.Sprintf(`
|
|
||||||
SELECT p.id, p.name, p.notes, p.metadata,
|
|
||||||
p.creator_id, u.name AS creator_name, p.is_public,
|
|
||||||
COALESCE(fc.cnt, 0) AS file_count,
|
|
||||||
COUNT(*) OVER() AS total
|
|
||||||
FROM data.pools p
|
|
||||||
JOIN core.users u ON u.id = p.creator_id
|
|
||||||
LEFT JOIN %s fc ON fc.pool_id = p.id
|
|
||||||
%s
|
|
||||||
ORDER BY %s %s NULLS LAST, p.id ASC
|
|
||||||
LIMIT $%d OFFSET $%d`, poolCountSubquery, where, sortCol, order, n, n+1)
|
|
||||||
|
|
||||||
args = append(args, limit, offset)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.List query: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[poolRowWithTotal])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.List scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]domain.Pool, len(collected))
|
|
||||||
total := 0
|
|
||||||
for i, row := range collected {
|
|
||||||
items[i] = toPool(row.poolRow)
|
|
||||||
total = row.Total
|
|
||||||
}
|
|
||||||
return &domain.PoolOffsetPage{
|
|
||||||
Items: items,
|
|
||||||
Total: total,
|
|
||||||
Offset: offset,
|
|
||||||
Limit: limit,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GetByID
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) GetByID(ctx context.Context, id uuid.UUID) (*domain.Pool, error) {
|
|
||||||
query := poolSelectFrom + `
|
|
||||||
WHERE p.id = $1`
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.GetByID: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[poolRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("PoolRepo.GetByID scan: %w", err)
|
|
||||||
}
|
|
||||||
p := toPool(row)
|
|
||||||
return &p, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Create
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) Create(ctx context.Context, p *domain.Pool) (*domain.Pool, error) {
|
|
||||||
const query = `
|
|
||||||
WITH ins AS (
|
|
||||||
INSERT INTO data.pools (name, notes, metadata, creator_id, is_public)
|
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT ins.id, ins.name, ins.notes, ins.metadata,
|
|
||||||
ins.creator_id, u.name AS creator_name, ins.is_public,
|
|
||||||
0 AS file_count
|
|
||||||
FROM ins
|
|
||||||
JOIN core.users u ON u.id = ins.creator_id`
|
|
||||||
|
|
||||||
var meta any
|
|
||||||
if len(p.Metadata) > 0 {
|
|
||||||
meta = p.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, p.Name, p.Notes, meta, p.CreatorID, p.IsPublic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.Create: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[poolRow])
|
|
||||||
if err != nil {
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("PoolRepo.Create scan: %w", err)
|
|
||||||
}
|
|
||||||
created := toPool(row)
|
|
||||||
return &created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Update
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*domain.Pool, error) {
|
|
||||||
const query = `
|
|
||||||
WITH upd AS (
|
|
||||||
UPDATE data.pools SET
|
|
||||||
name = $2,
|
|
||||||
notes = $3,
|
|
||||||
metadata = COALESCE($4, metadata),
|
|
||||||
is_public = $5
|
|
||||||
WHERE id = $1
|
|
||||||
RETURNING *
|
|
||||||
)
|
|
||||||
SELECT upd.id, upd.name, upd.notes, upd.metadata,
|
|
||||||
upd.creator_id, u.name AS creator_name, upd.is_public,
|
|
||||||
COALESCE(fc.cnt, 0) AS file_count
|
|
||||||
FROM upd
|
|
||||||
JOIN core.users u ON u.id = upd.creator_id
|
|
||||||
LEFT JOIN (SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool WHERE pool_id = $1 GROUP BY pool_id) fc
|
|
||||||
ON fc.pool_id = upd.id`
|
|
||||||
|
|
||||||
var meta any
|
|
||||||
if len(p.Metadata) > 0 {
|
|
||||||
meta = p.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.Update: %w", err)
|
|
||||||
}
|
|
||||||
row, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[poolRow])
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
|
||||||
return nil, domain.ErrNotFound
|
|
||||||
}
|
|
||||||
if isPgUniqueViolation(err) {
|
|
||||||
return nil, domain.ErrConflict
|
|
||||||
}
|
|
||||||
return nil, fmt.Errorf("PoolRepo.Update scan: %w", err)
|
|
||||||
}
|
|
||||||
updated := toPool(row)
|
|
||||||
return &updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Delete
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
const query = `DELETE FROM data.pools WHERE id = $1`
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
ct, err := q.Exec(ctx, query, id)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.Delete: %w", err)
|
|
||||||
}
|
|
||||||
if ct.RowsAffected() == 0 {
|
|
||||||
return domain.ErrNotFound
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ListFiles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// fileSelectForPool is the column list for pool file queries (without position).
|
|
||||||
const fileSelectForPool = `
|
|
||||||
f.id, f.original_name,
|
|
||||||
mt.name AS mime_type, mt.extension AS mime_extension,
|
|
||||||
f.content_datetime, f.notes, f.metadata, f.exif, f.phash,
|
|
||||||
f.creator_id, u.name AS creator_name,
|
|
||||||
f.is_public, f.is_deleted`
|
|
||||||
|
|
||||||
func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
|
|
||||||
limit := db.ClampLimit(params.Limit, 50, 200)
|
|
||||||
|
|
||||||
args := []any{poolID}
|
|
||||||
n := 2
|
|
||||||
var conds []string
|
|
||||||
|
|
||||||
conds = append(conds, "fp.pool_id = $1")
|
|
||||||
conds = append(conds, "f.is_deleted = false")
|
|
||||||
|
|
||||||
if params.Filter != "" {
|
|
||||||
filterSQL, nextN, filterArgs, err := ParseFilter(params.Filter, n)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
|
|
||||||
}
|
|
||||||
if filterSQL != "" {
|
|
||||||
conds = append(conds, filterSQL)
|
|
||||||
n = nextN
|
|
||||||
args = append(args, filterArgs...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cursor condition.
|
|
||||||
var orderBy string
|
|
||||||
if params.Cursor != "" {
|
|
||||||
cur, err := decodePoolCursor(params.Cursor)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", domain.ErrValidation, err)
|
|
||||||
}
|
|
||||||
fileID, err := uuid.Parse(cur.FileID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, domain.ErrValidation
|
|
||||||
}
|
|
||||||
conds = append(conds, fmt.Sprintf(
|
|
||||||
"(fp.position > $%d OR (fp.position = $%d AND fp.file_id > $%d))",
|
|
||||||
n, n, n+1))
|
|
||||||
args = append(args, cur.Position, fileID)
|
|
||||||
n += 2
|
|
||||||
}
|
|
||||||
orderBy = "fp.position ASC, fp.file_id ASC"
|
|
||||||
|
|
||||||
where := "WHERE " + strings.Join(conds, " AND ")
|
|
||||||
args = append(args, limit+1)
|
|
||||||
|
|
||||||
sqlStr := fmt.Sprintf(`
|
|
||||||
SELECT %s, fp.position
|
|
||||||
FROM data.file_pool fp
|
|
||||||
JOIN data.files f ON f.id = fp.file_id
|
|
||||||
JOIN core.mime_types mt ON mt.id = f.mime_id
|
|
||||||
JOIN core.users u ON u.id = f.creator_id
|
|
||||||
%s
|
|
||||||
ORDER BY %s
|
|
||||||
LIMIT $%d`, fileSelectForPool, where, orderBy, n)
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.ListFiles query: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[poolFileRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.ListFiles scan: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
hasMore := len(collected) > limit
|
|
||||||
if hasMore {
|
|
||||||
collected = collected[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]domain.PoolFile, len(collected))
|
|
||||||
for i, row := range collected {
|
|
||||||
items[i] = toPoolFile(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
page := &domain.PoolFilePage{Items: items}
|
|
||||||
|
|
||||||
if hasMore && len(collected) > 0 {
|
|
||||||
last := collected[len(collected)-1]
|
|
||||||
cur := encodePoolCursor(poolFileCursor{
|
|
||||||
Position: last.Position,
|
|
||||||
FileID: last.ID.String(),
|
|
||||||
})
|
|
||||||
page.NextCursor = &cur
|
|
||||||
}
|
|
||||||
|
|
||||||
// Batch-load tags.
|
|
||||||
if len(items) > 0 {
|
|
||||||
fileIDs := make([]uuid.UUID, len(items))
|
|
||||||
for i, pf := range items {
|
|
||||||
fileIDs[i] = pf.File.ID
|
|
||||||
}
|
|
||||||
tagMap, err := r.loadPoolTagsBatch(ctx, fileIDs)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for i, pf := range items {
|
|
||||||
page.Items[i].File.Tags = tagMap[pf.File.ID]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return page, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadPoolTagsBatch re-uses the same pattern as FileRepo.loadTagsBatch.
|
|
||||||
func (r *PoolRepo) loadPoolTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[uuid.UUID][]domain.Tag, error) {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
placeholders := make([]string, len(fileIDs))
|
|
||||||
args := make([]any, len(fileIDs))
|
|
||||||
for i, id := range fileIDs {
|
|
||||||
placeholders[i] = fmt.Sprintf("$%d", i+1)
|
|
||||||
args[i] = id
|
|
||||||
}
|
|
||||||
sqlStr := fmt.Sprintf(`
|
|
||||||
SELECT ft.file_id,
|
|
||||||
t.id, t.name, t.notes, t.color,
|
|
||||||
t.category_id,
|
|
||||||
c.name AS category_name,
|
|
||||||
c.color AS category_color,
|
|
||||||
t.metadata, t.creator_id, u.name AS creator_name, t.is_public
|
|
||||||
FROM data.file_tag ft
|
|
||||||
JOIN data.tags t ON t.id = ft.tag_id
|
|
||||||
JOIN core.users u ON u.id = t.creator_id
|
|
||||||
LEFT JOIN data.categories c ON c.id = t.category_id
|
|
||||||
WHERE ft.file_id IN (%s)
|
|
||||||
ORDER BY ft.file_id, t.name`, strings.Join(placeholders, ","))
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
rows, err := q.Query(ctx, sqlStr, args...)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.loadPoolTagsBatch: %w", err)
|
|
||||||
}
|
|
||||||
collected, err := pgx.CollectRows(rows, pgx.RowToStructByName[fileTagRow])
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("PoolRepo.loadPoolTagsBatch scan: %w", err)
|
|
||||||
}
|
|
||||||
result := make(map[uuid.UUID][]domain.Tag, len(fileIDs))
|
|
||||||
for _, fid := range fileIDs {
|
|
||||||
result[fid] = []domain.Tag{}
|
|
||||||
}
|
|
||||||
for _, row := range collected {
|
|
||||||
result[row.FileID] = append(result[row.FileID], toTagFromFileTag(row))
|
|
||||||
}
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// AddFiles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// AddFiles inserts files into the pool. When position is nil, files are
|
|
||||||
// appended after the last existing file (MAX(position) + 1000 * i).
|
|
||||||
// When position is provided (0-indexed), files are inserted at that index
|
|
||||||
// and all pool positions are reassigned in one shot.
|
|
||||||
func (r *PoolRepo) AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
|
|
||||||
if position == nil {
|
|
||||||
// Append: get current max position, then bulk-insert.
|
|
||||||
var maxPos int
|
|
||||||
row := q.QueryRow(ctx, `SELECT COALESCE(MAX(position), 0) FROM data.file_pool WHERE pool_id = $1`, poolID)
|
|
||||||
if err := row.Scan(&maxPos); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.AddFiles maxPos: %w", err)
|
|
||||||
}
|
|
||||||
const ins = `INSERT INTO data.file_pool (file_id, pool_id, position) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`
|
|
||||||
for i, fid := range fileIDs {
|
|
||||||
if _, err := q.Exec(ctx, ins, fid, poolID, maxPos+1000*(i+1)); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.AddFiles insert: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Positional insert: rebuild the full ordered list and reassign.
|
|
||||||
return r.insertAtPosition(ctx, q, poolID, fileIDs, *position)
|
|
||||||
}
|
|
||||||
|
|
||||||
// insertAtPosition fetches the current ordered file list, splices in the new
|
|
||||||
// IDs at index pos (0-indexed, clamped), then does a full position reassign.
|
|
||||||
func (r *PoolRepo) insertAtPosition(ctx context.Context, q db.Querier, poolID uuid.UUID, newIDs []uuid.UUID, pos int) error {
|
|
||||||
// 1. Fetch current order.
|
|
||||||
rows, err := q.Query(ctx, `SELECT file_id FROM data.file_pool WHERE pool_id = $1 ORDER BY position ASC, file_id ASC`, poolID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.insertAtPosition fetch: %w", err)
|
|
||||||
}
|
|
||||||
var current []uuid.UUID
|
|
||||||
for rows.Next() {
|
|
||||||
var fid uuid.UUID
|
|
||||||
if err := rows.Scan(&fid); err != nil {
|
|
||||||
rows.Close()
|
|
||||||
return fmt.Errorf("PoolRepo.insertAtPosition scan: %w", err)
|
|
||||||
}
|
|
||||||
current = append(current, fid)
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.insertAtPosition rows: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Build new ordered list, skipping already-present IDs from newIDs.
|
|
||||||
present := make(map[uuid.UUID]bool, len(current))
|
|
||||||
for _, fid := range current {
|
|
||||||
present[fid] = true
|
|
||||||
}
|
|
||||||
toAdd := make([]uuid.UUID, 0, len(newIDs))
|
|
||||||
for _, fid := range newIDs {
|
|
||||||
if !present[fid] {
|
|
||||||
toAdd = append(toAdd, fid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(toAdd) == 0 {
|
|
||||||
return nil // all already present
|
|
||||||
}
|
|
||||||
|
|
||||||
if pos < 0 {
|
|
||||||
pos = 0
|
|
||||||
}
|
|
||||||
if pos > len(current) {
|
|
||||||
pos = len(current)
|
|
||||||
}
|
|
||||||
|
|
||||||
ordered := make([]uuid.UUID, 0, len(current)+len(toAdd))
|
|
||||||
ordered = append(ordered, current[:pos]...)
|
|
||||||
ordered = append(ordered, toAdd...)
|
|
||||||
ordered = append(ordered, current[pos:]...)
|
|
||||||
|
|
||||||
// 3. Full replace.
|
|
||||||
return r.reassignPositions(ctx, q, poolID, ordered)
|
|
||||||
}
|
|
||||||
|
|
||||||
// reassignPositions does a DELETE + bulk INSERT for the pool with positions
|
|
||||||
// 1000, 2000, 3000, ...
|
|
||||||
func (r *PoolRepo) reassignPositions(ctx context.Context, q db.Querier, poolID uuid.UUID, ordered []uuid.UUID) error {
|
|
||||||
if _, err := q.Exec(ctx, `DELETE FROM data.file_pool WHERE pool_id = $1`, poolID); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.reassignPositions delete: %w", err)
|
|
||||||
}
|
|
||||||
if len(ordered) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
const ins = `INSERT INTO data.file_pool (file_id, pool_id, position) VALUES ($1, $2, $3)`
|
|
||||||
for i, fid := range ordered {
|
|
||||||
if _, err := q.Exec(ctx, ins, fid, poolID, 1000*(i+1)); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.reassignPositions insert: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// RemoveFiles
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (r *PoolRepo) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
|
||||||
if len(fileIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
placeholders := make([]string, len(fileIDs))
|
|
||||||
args := make([]any, len(fileIDs)+1)
|
|
||||||
args[0] = poolID
|
|
||||||
for i, fid := range fileIDs {
|
|
||||||
placeholders[i] = fmt.Sprintf("$%d", i+2)
|
|
||||||
args[i+1] = fid
|
|
||||||
}
|
|
||||||
query := fmt.Sprintf(
|
|
||||||
`DELETE FROM data.file_pool WHERE pool_id = $1 AND file_id IN (%s)`,
|
|
||||||
strings.Join(placeholders, ","))
|
|
||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
if _, err := q.Exec(ctx, query, args...); err != nil {
|
|
||||||
return fmt.Errorf("PoolRepo.RemoveFiles: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Reorder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// Reorder replaces the full ordered sequence with positions 1000, 2000, …
|
|
||||||
// Only file IDs already in the pool are allowed; unknown IDs are silently
|
|
||||||
// skipped to avoid integrity violations.
|
|
||||||
func (r *PoolRepo) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
|
||||||
q := connOrTx(ctx, r.pool)
|
|
||||||
return r.reassignPositions(ctx, q, poolID, fileIDs)
|
|
||||||
}
|
|
||||||
@ -1,235 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CategoryHandler handles all /categories endpoints.
|
|
||||||
type CategoryHandler struct {
|
|
||||||
categorySvc *service.CategoryService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCategoryHandler creates a CategoryHandler.
|
|
||||||
func NewCategoryHandler(categorySvc *service.CategoryService) *CategoryHandler {
|
|
||||||
return &CategoryHandler{categorySvc: categorySvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type categoryJSON struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Color *string `json:"color"`
|
|
||||||
CreatorID int16 `json:"creator_id"`
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toCategoryJSON(c domain.Category) categoryJSON {
|
|
||||||
return categoryJSON{
|
|
||||||
ID: c.ID.String(),
|
|
||||||
Name: c.Name,
|
|
||||||
Notes: c.Notes,
|
|
||||||
Color: c.Color,
|
|
||||||
CreatorID: c.CreatorID,
|
|
||||||
CreatorName: c.CreatorName,
|
|
||||||
IsPublic: c.IsPublic,
|
|
||||||
CreatedAt: c.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parseCategoryID(c *gin.Context) (uuid.UUID, bool) {
|
|
||||||
id, err := uuid.Parse(c.Param("category_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return uuid.UUID{}, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /categories
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) List(c *gin.Context) {
|
|
||||||
params := parseOffsetParams(c, "created")
|
|
||||||
|
|
||||||
page, err := h.categorySvc.List(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]categoryJSON, len(page.Items))
|
|
||||||
for i, cat := range page.Items {
|
|
||||||
items[i] = toCategoryJSON(cat)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": page.Total,
|
|
||||||
"offset": page.Offset,
|
|
||||||
"limit": page.Limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /categories
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) Create(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
Color *string `json:"color"`
|
|
||||||
IsPublic *bool `json:"is_public"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := h.categorySvc.Create(c.Request.Context(), service.CategoryParams{
|
|
||||||
Name: body.Name,
|
|
||||||
Notes: body.Notes,
|
|
||||||
Color: body.Color,
|
|
||||||
IsPublic: body.IsPublic,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusCreated, toCategoryJSON(*created))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /categories/:category_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) Get(c *gin.Context) {
|
|
||||||
id, ok := parseCategoryID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
cat, err := h.categorySvc.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toCategoryJSON(*cat))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PATCH /categories/:category_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) Update(c *gin.Context) {
|
|
||||||
id, ok := parseCategoryID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use a raw map to detect explicitly-null fields.
|
|
||||||
var raw map[string]any
|
|
||||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := service.CategoryParams{}
|
|
||||||
|
|
||||||
if v, ok := raw["name"]; ok {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
params.Name = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := raw["notes"]; ok {
|
|
||||||
if raw["notes"] == nil {
|
|
||||||
empty := ""
|
|
||||||
params.Notes = &empty
|
|
||||||
} else if s, ok := raw["notes"].(string); ok {
|
|
||||||
params.Notes = &s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := raw["color"]; ok {
|
|
||||||
if raw["color"] == nil {
|
|
||||||
empty := ""
|
|
||||||
params.Color = &empty
|
|
||||||
} else if s, ok := raw["color"].(string); ok {
|
|
||||||
params.Color = &s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := raw["is_public"]; ok {
|
|
||||||
if b, ok := v.(bool); ok {
|
|
||||||
params.IsPublic = &b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := h.categorySvc.Update(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toCategoryJSON(*updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /categories/:category_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) Delete(c *gin.Context) {
|
|
||||||
id, ok := parseCategoryID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.categorySvc.Delete(c.Request.Context(), id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /categories/:category_id/tags
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *CategoryHandler) ListTags(c *gin.Context) {
|
|
||||||
id, ok := parseCategoryID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := parseOffsetParams(c, "created")
|
|
||||||
|
|
||||||
page, err := h.categorySvc.ListTags(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]tagJSON, len(page.Items))
|
|
||||||
for i, t := range page.Items {
|
|
||||||
items[i] = toTagJSON(t)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": page.Total,
|
|
||||||
"offset": page.Offset,
|
|
||||||
"limit": page.Limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,356 +0,0 @@
|
|||||||
package handler
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
"tanabata/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PoolHandler handles all /pools endpoints.
|
|
||||||
type PoolHandler struct {
|
|
||||||
poolSvc *service.PoolService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPoolHandler creates a PoolHandler.
|
|
||||||
func NewPoolHandler(poolSvc *service.PoolService) *PoolHandler {
|
|
||||||
return &PoolHandler{poolSvc: poolSvc}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Response types
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
type poolJSON struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
CreatorID int16 `json:"creator_id"`
|
|
||||||
CreatorName string `json:"creator_name"`
|
|
||||||
IsPublic bool `json:"is_public"`
|
|
||||||
FileCount int `json:"file_count"`
|
|
||||||
CreatedAt string `json:"created_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type poolFileJSON struct {
|
|
||||||
fileJSON
|
|
||||||
Position int `json:"position"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPoolJSON(p domain.Pool) poolJSON {
|
|
||||||
return poolJSON{
|
|
||||||
ID: p.ID.String(),
|
|
||||||
Name: p.Name,
|
|
||||||
Notes: p.Notes,
|
|
||||||
CreatorID: p.CreatorID,
|
|
||||||
CreatorName: p.CreatorName,
|
|
||||||
IsPublic: p.IsPublic,
|
|
||||||
FileCount: p.FileCount,
|
|
||||||
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toPoolFileJSON(pf domain.PoolFile) poolFileJSON {
|
|
||||||
return poolFileJSON{
|
|
||||||
fileJSON: toFileJSON(pf.File),
|
|
||||||
Position: pf.Position,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func parsePoolID(c *gin.Context) (uuid.UUID, bool) {
|
|
||||||
id, err := uuid.Parse(c.Param("pool_id"))
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return uuid.UUID{}, false
|
|
||||||
}
|
|
||||||
return id, true
|
|
||||||
}
|
|
||||||
|
|
||||||
func parsePoolFileParams(c *gin.Context) port.PoolFileListParams {
|
|
||||||
limit := 50
|
|
||||||
if s := c.Query("limit"); s != "" {
|
|
||||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
|
|
||||||
limit = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return port.PoolFileListParams{
|
|
||||||
Cursor: c.Query("cursor"),
|
|
||||||
Limit: limit,
|
|
||||||
Filter: c.Query("filter"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /pools
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) List(c *gin.Context) {
|
|
||||||
params := parseOffsetParams(c, "created")
|
|
||||||
|
|
||||||
page, err := h.poolSvc.List(c.Request.Context(), params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]poolJSON, len(page.Items))
|
|
||||||
for i, p := range page.Items {
|
|
||||||
items[i] = toPoolJSON(p)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"total": page.Total,
|
|
||||||
"offset": page.Offset,
|
|
||||||
"limit": page.Limit,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /pools
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) Create(c *gin.Context) {
|
|
||||||
var body struct {
|
|
||||||
Name string `json:"name" binding:"required"`
|
|
||||||
Notes *string `json:"notes"`
|
|
||||||
IsPublic *bool `json:"is_public"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := h.poolSvc.Create(c.Request.Context(), service.PoolParams{
|
|
||||||
Name: body.Name,
|
|
||||||
Notes: body.Notes,
|
|
||||||
IsPublic: body.IsPublic,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusCreated, toPoolJSON(*created))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /pools/:pool_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) Get(c *gin.Context) {
|
|
||||||
id, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p, err := h.poolSvc.Get(c.Request.Context(), id)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toPoolJSON(*p))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PATCH /pools/:pool_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) Update(c *gin.Context) {
|
|
||||||
id, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var raw map[string]any
|
|
||||||
if err := c.ShouldBindJSON(&raw); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := service.PoolParams{}
|
|
||||||
if v, ok := raw["name"]; ok {
|
|
||||||
if s, ok := v.(string); ok {
|
|
||||||
params.Name = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, ok := raw["notes"]; ok {
|
|
||||||
if raw["notes"] == nil {
|
|
||||||
empty := ""
|
|
||||||
params.Notes = &empty
|
|
||||||
} else if s, ok := raw["notes"].(string); ok {
|
|
||||||
params.Notes = &s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if v, ok := raw["is_public"]; ok {
|
|
||||||
if b, ok := v.(bool); ok {
|
|
||||||
params.IsPublic = &b
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := h.poolSvc.Update(c.Request.Context(), id, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, toPoolJSON(*updated))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// DELETE /pools/:pool_id
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) Delete(c *gin.Context) {
|
|
||||||
id, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.poolSvc.Delete(c.Request.Context(), id); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// GET /pools/:pool_id/files
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) ListFiles(c *gin.Context) {
|
|
||||||
poolID, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
params := parsePoolFileParams(c)
|
|
||||||
|
|
||||||
page, err := h.poolSvc.ListFiles(c.Request.Context(), poolID, params)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]poolFileJSON, len(page.Items))
|
|
||||||
for i, pf := range page.Items {
|
|
||||||
items[i] = toPoolFileJSON(pf)
|
|
||||||
}
|
|
||||||
respondJSON(c, http.StatusOK, gin.H{
|
|
||||||
"items": items,
|
|
||||||
"next_cursor": page.NextCursor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /pools/:pool_id/files
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) AddFiles(c *gin.Context) {
|
|
||||||
poolID, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
Position *int `json:"position"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs := make([]uuid.UUID, 0, len(body.FileIDs))
|
|
||||||
for _, s := range body.FileIDs {
|
|
||||||
id, err := uuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileIDs = append(fileIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.poolSvc.AddFiles(c.Request.Context(), poolID, fileIDs, body.Position); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// POST /pools/:pool_id/files/remove
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) RemoveFiles(c *gin.Context) {
|
|
||||||
poolID, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs := make([]uuid.UUID, 0, len(body.FileIDs))
|
|
||||||
for _, s := range body.FileIDs {
|
|
||||||
id, err := uuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileIDs = append(fileIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.poolSvc.RemoveFiles(c.Request.Context(), poolID, fileIDs); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// PUT /pools/:pool_id/files/reorder
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (h *PoolHandler) Reorder(c *gin.Context) {
|
|
||||||
poolID, ok := parsePoolID(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var body struct {
|
|
||||||
FileIDs []string `json:"file_ids" binding:"required"`
|
|
||||||
}
|
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileIDs := make([]uuid.UUID, 0, len(body.FileIDs))
|
|
||||||
for _, s := range body.FileIDs {
|
|
||||||
id, err := uuid.Parse(s)
|
|
||||||
if err != nil {
|
|
||||||
respondError(c, domain.ErrValidation)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fileIDs = append(fileIDs, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.poolSvc.Reorder(c.Request.Context(), poolID, fileIDs); err != nil {
|
|
||||||
respondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
@ -12,8 +12,6 @@ func NewRouter(
|
|||||||
authHandler *AuthHandler,
|
authHandler *AuthHandler,
|
||||||
fileHandler *FileHandler,
|
fileHandler *FileHandler,
|
||||||
tagHandler *TagHandler,
|
tagHandler *TagHandler,
|
||||||
categoryHandler *CategoryHandler,
|
|
||||||
poolHandler *PoolHandler,
|
|
||||||
) *gin.Engine {
|
) *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
@ -93,40 +91,5 @@ func NewRouter(
|
|||||||
tags.DELETE("/:tag_id/rules/:then_tag_id", tagHandler.DeleteRule)
|
tags.DELETE("/:tag_id/rules/:then_tag_id", tagHandler.DeleteRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Categories (all require auth)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
categories := v1.Group("/categories", auth.Handle())
|
|
||||||
{
|
|
||||||
categories.GET("", categoryHandler.List)
|
|
||||||
categories.POST("", categoryHandler.Create)
|
|
||||||
|
|
||||||
categories.GET("/:category_id", categoryHandler.Get)
|
|
||||||
categories.PATCH("/:category_id", categoryHandler.Update)
|
|
||||||
categories.DELETE("/:category_id", categoryHandler.Delete)
|
|
||||||
|
|
||||||
categories.GET("/:category_id/tags", categoryHandler.ListTags)
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Pools (all require auth)
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
pools := v1.Group("/pools", auth.Handle())
|
|
||||||
{
|
|
||||||
pools.GET("", poolHandler.List)
|
|
||||||
pools.POST("", poolHandler.Create)
|
|
||||||
|
|
||||||
pools.GET("/:pool_id", poolHandler.Get)
|
|
||||||
pools.PATCH("/:pool_id", poolHandler.Update)
|
|
||||||
pools.DELETE("/:pool_id", poolHandler.Delete)
|
|
||||||
|
|
||||||
// Sub-routes registered before /:pool_id/files to avoid param conflicts.
|
|
||||||
pools.POST("/:pool_id/files/remove", poolHandler.RemoveFiles)
|
|
||||||
pools.PUT("/:pool_id/files/reorder", poolHandler.Reorder)
|
|
||||||
|
|
||||||
pools.GET("/:pool_id/files", poolHandler.ListFiles)
|
|
||||||
pools.POST("/:pool_id/files", poolHandler.AddFiles)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@ -1,164 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
const categoryObjectType = "category"
|
|
||||||
const categoryObjectTypeID int16 = 3 // third row in 007_seed_data.sql object_types
|
|
||||||
|
|
||||||
// CategoryParams holds the fields for creating or patching a category.
|
|
||||||
type CategoryParams struct {
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Color *string // nil = no change; pointer to empty string = clear
|
|
||||||
Metadata json.RawMessage
|
|
||||||
IsPublic *bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// CategoryService handles category CRUD with ACL enforcement and audit logging.
|
|
||||||
type CategoryService struct {
|
|
||||||
categories port.CategoryRepo
|
|
||||||
tags port.TagRepo
|
|
||||||
acl *ACLService
|
|
||||||
audit *AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCategoryService creates a CategoryService.
|
|
||||||
func NewCategoryService(
|
|
||||||
categories port.CategoryRepo,
|
|
||||||
tags port.TagRepo,
|
|
||||||
acl *ACLService,
|
|
||||||
audit *AuditService,
|
|
||||||
) *CategoryService {
|
|
||||||
return &CategoryService{
|
|
||||||
categories: categories,
|
|
||||||
tags: tags,
|
|
||||||
acl: acl,
|
|
||||||
audit: audit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// List returns a paginated, optionally filtered list of categories.
|
|
||||||
func (s *CategoryService) List(ctx context.Context, params port.OffsetParams) (*domain.CategoryOffsetPage, error) {
|
|
||||||
return s.categories.List(ctx, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a category by ID.
|
|
||||||
func (s *CategoryService) Get(ctx context.Context, id uuid.UUID) (*domain.Category, error) {
|
|
||||||
return s.categories.GetByID(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new category record.
|
|
||||||
func (s *CategoryService) Create(ctx context.Context, p CategoryParams) (*domain.Category, error) {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
c := &domain.Category{
|
|
||||||
Name: p.Name,
|
|
||||||
Notes: p.Notes,
|
|
||||||
Color: p.Color,
|
|
||||||
Metadata: p.Metadata,
|
|
||||||
CreatorID: userID,
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
c.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := s.categories.Create(ctx, c)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := categoryObjectType
|
|
||||||
_ = s.audit.Log(ctx, "category_create", &objType, &created.ID, nil)
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies a partial patch to a category.
|
|
||||||
func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, p CategoryParams) (*domain.Category, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
current, err := s.categories.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, categoryObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := *current
|
|
||||||
if p.Name != "" {
|
|
||||||
patch.Name = p.Name
|
|
||||||
}
|
|
||||||
if p.Notes != nil {
|
|
||||||
patch.Notes = p.Notes
|
|
||||||
}
|
|
||||||
if p.Color != nil {
|
|
||||||
patch.Color = p.Color
|
|
||||||
}
|
|
||||||
if len(p.Metadata) > 0 {
|
|
||||||
patch.Metadata = p.Metadata
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
patch.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.categories.Update(ctx, id, &patch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := categoryObjectType
|
|
||||||
_ = s.audit.Log(ctx, "category_edit", &objType, &id, nil)
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a category by ID, enforcing edit ACL.
|
|
||||||
func (s *CategoryService) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
c, err := s.categories.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, c.CreatorID, categoryObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.categories.Delete(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := categoryObjectType
|
|
||||||
_ = s.audit.Log(ctx, "category_delete", &objType, &id, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tags in category
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ListTags returns a paginated list of tags belonging to this category.
|
|
||||||
func (s *CategoryService) ListTags(ctx context.Context, categoryID uuid.UUID, params port.OffsetParams) (*domain.TagOffsetPage, error) {
|
|
||||||
return s.tags.ListByCategory(ctx, categoryID, params)
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
|
|
||||||
"tanabata/backend/internal/domain"
|
|
||||||
"tanabata/backend/internal/port"
|
|
||||||
)
|
|
||||||
|
|
||||||
const poolObjectType = "pool"
|
|
||||||
const poolObjectTypeID int16 = 4 // fourth row in 007_seed_data.sql object_types
|
|
||||||
|
|
||||||
// PoolParams holds the fields for creating or patching a pool.
|
|
||||||
type PoolParams struct {
|
|
||||||
Name string
|
|
||||||
Notes *string
|
|
||||||
Metadata json.RawMessage
|
|
||||||
IsPublic *bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// PoolService handles pool CRUD and pool–file management with ACL + audit.
|
|
||||||
type PoolService struct {
|
|
||||||
pools port.PoolRepo
|
|
||||||
acl *ACLService
|
|
||||||
audit *AuditService
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewPoolService creates a PoolService.
|
|
||||||
func NewPoolService(
|
|
||||||
pools port.PoolRepo,
|
|
||||||
acl *ACLService,
|
|
||||||
audit *AuditService,
|
|
||||||
) *PoolService {
|
|
||||||
return &PoolService{pools: pools, acl: acl, audit: audit}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// CRUD
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// List returns a paginated list of pools.
|
|
||||||
func (s *PoolService) List(ctx context.Context, params port.OffsetParams) (*domain.PoolOffsetPage, error) {
|
|
||||||
return s.pools.List(ctx, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns a pool by ID.
|
|
||||||
func (s *PoolService) Get(ctx context.Context, id uuid.UUID) (*domain.Pool, error) {
|
|
||||||
return s.pools.GetByID(ctx, id)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create inserts a new pool.
|
|
||||||
func (s *PoolService) Create(ctx context.Context, p PoolParams) (*domain.Pool, error) {
|
|
||||||
userID, _, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
pool := &domain.Pool{
|
|
||||||
Name: p.Name,
|
|
||||||
Notes: p.Notes,
|
|
||||||
Metadata: p.Metadata,
|
|
||||||
CreatorID: userID,
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
pool.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := s.pools.Create(ctx, pool)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := poolObjectType
|
|
||||||
_ = s.audit.Log(ctx, "pool_create", &objType, &created.ID, nil)
|
|
||||||
return created, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update applies a partial patch to a pool.
|
|
||||||
func (s *PoolService) Update(ctx context.Context, id uuid.UUID, p PoolParams) (*domain.Pool, error) {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
current, err := s.pools.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, current.CreatorID, poolObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return nil, domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
patch := *current
|
|
||||||
if p.Name != "" {
|
|
||||||
patch.Name = p.Name
|
|
||||||
}
|
|
||||||
if p.Notes != nil {
|
|
||||||
patch.Notes = p.Notes
|
|
||||||
}
|
|
||||||
if len(p.Metadata) > 0 {
|
|
||||||
patch.Metadata = p.Metadata
|
|
||||||
}
|
|
||||||
if p.IsPublic != nil {
|
|
||||||
patch.IsPublic = *p.IsPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := s.pools.Update(ctx, id, &patch)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := poolObjectType
|
|
||||||
_ = s.audit.Log(ctx, "pool_edit", &objType, &id, nil)
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete removes a pool by ID, enforcing edit ACL.
|
|
||||||
func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error {
|
|
||||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
|
||||||
|
|
||||||
pool, err := s.pools.GetByID(ctx, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, pool.CreatorID, poolObjectTypeID, id)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return domain.ErrForbidden
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := s.pools.Delete(ctx, id); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
objType := poolObjectType
|
|
||||||
_ = s.audit.Log(ctx, "pool_delete", &objType, &id, nil)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Pool–file operations
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
// ListFiles returns cursor-paginated files within a pool ordered by position.
|
|
||||||
func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
|
|
||||||
return s.pools.ListFiles(ctx, poolID, params)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddFiles adds files to a pool at the given position (nil = append).
|
|
||||||
func (s *PoolService) AddFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID, position *int) error {
|
|
||||||
if err := s.pools.AddFiles(ctx, poolID, fileIDs, position); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
objType := poolObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_pool_add", &objType, &poolID, map[string]any{"count": len(fileIDs)})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoveFiles removes files from a pool.
|
|
||||||
func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
|
||||||
if err := s.pools.RemoveFiles(ctx, poolID, fileIDs); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
objType := poolObjectType
|
|
||||||
_ = s.audit.Log(ctx, "file_pool_remove", &objType, &poolID, map[string]any{"count": len(fileIDs)})
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reorder sets the full ordered sequence of file IDs within a pool.
|
|
||||||
func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
|
||||||
return s.pools.Reorder(ctx, poolID, fileIDs)
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user