Pools now store a sort setting (sort_key + sort_order). "manual" keeps the user-arranged order in file_pool.position and allows drag-to-reorder; any other key (content_datetime, created, original_name) sorts the pool's files automatically server-side, in which case reordering is rejected. Manual order is always ascending by position — direction does not apply. Backend: add the columns (sort_key defaults to 'manual'), generalise the pool-files keyset cursor to page by the active sort, persist the setting on create/update, and guard reorder against non-manual pools. Frontend: a sort dropdown (+ direction toggle) on the pool page that PATCHes the pool and reloads; drag-to-reorder is disabled unless the pool is manual. Closes the "automatic sorting in pools" requirement (REQUIREMENTS.md §4.6.5). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,8 @@ type poolRow struct {
|
||||
CreatorID int16 `db:"creator_id"`
|
||||
CreatorName string `db:"creator_name"`
|
||||
IsPublic bool `db:"is_public"`
|
||||
SortKey string `db:"sort_key"`
|
||||
SortOrder string `db:"sort_order"`
|
||||
FileCount int `db:"file_count"`
|
||||
}
|
||||
|
||||
@@ -68,6 +70,8 @@ func toPool(r poolRow) domain.Pool {
|
||||
CreatorID: r.CreatorID,
|
||||
CreatorName: r.CreatorName,
|
||||
IsPublic: r.IsPublic,
|
||||
SortKey: r.SortKey,
|
||||
SortOrder: r.SortOrder,
|
||||
FileCount: r.FileCount,
|
||||
CreatedAt: domain.UUIDCreatedAt(r.ID),
|
||||
}
|
||||
@@ -103,9 +107,15 @@ func toPoolFile(r poolFileRow) domain.PoolFile {
|
||||
// Cursor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// poolFileCursor is the keyset paging cursor for pool files. Which fields are
|
||||
// populated depends on the pool's sort key: Pos for manual (position), Val for
|
||||
// content_datetime (RFC3339Nano) / original_name (the coalesced name); the
|
||||
// "created" sort orders by file id alone, so only FileID matters. FileID is
|
||||
// always the final tiebreak.
|
||||
type poolFileCursor struct {
|
||||
Position int `json:"p"`
|
||||
FileID string `json:"id"`
|
||||
Pos int `json:"p,omitempty"`
|
||||
Val string `json:"v,omitempty"`
|
||||
FileID string `json:"id"`
|
||||
}
|
||||
|
||||
func encodePoolCursor(c poolFileCursor) string {
|
||||
@@ -135,6 +145,7 @@ const poolCountSubquery = `(SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool
|
||||
const poolSelectFrom = `
|
||||
SELECT p.id, p.name, p.notes, p.metadata,
|
||||
p.creator_id, u.name AS creator_name, p.is_public,
|
||||
p.sort_key, p.sort_order,
|
||||
COALESCE(fc.cnt, 0) AS file_count
|
||||
FROM data.pools p
|
||||
JOIN core.users u ON u.id = p.creator_id
|
||||
@@ -147,6 +158,29 @@ func poolSortColumn(s string) string {
|
||||
return "p.id" // "created"
|
||||
}
|
||||
|
||||
// poolFileSort maps a pool's stored sort settings to the SQL column expression,
|
||||
// the ORDER BY direction, and the keyset comparison operator used for paging.
|
||||
// The column is chosen so it is never NULL (original_name is coalesced), which
|
||||
// keeps the keyset comparison total.
|
||||
func poolFileSort(sortKey, sortOrder string) (col, dir, cmp string) {
|
||||
dir, cmp = "ASC", ">"
|
||||
if strings.EqualFold(sortOrder, domain.SortOrderDesc) {
|
||||
dir, cmp = "DESC", "<"
|
||||
}
|
||||
switch sortKey {
|
||||
case domain.PoolSortContentDatetime:
|
||||
col = "f.content_datetime"
|
||||
case domain.PoolSortOriginalName:
|
||||
col = "COALESCE(f.original_name, '')"
|
||||
case domain.PoolSortCreated:
|
||||
col = "f.id"
|
||||
default: // manual — the user-arranged sequence; direction does not apply
|
||||
col = "fp.position"
|
||||
dir, cmp = "ASC", ">"
|
||||
}
|
||||
return col, dir, cmp
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PoolRepo
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -207,6 +241,7 @@ func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain.
|
||||
query := fmt.Sprintf(`
|
||||
SELECT p.id, p.name, p.notes, p.metadata,
|
||||
p.creator_id, u.name AS creator_name, p.is_public,
|
||||
p.sort_key, p.sort_order,
|
||||
COALESCE(fc.cnt, 0) AS file_count,
|
||||
COUNT(*) OVER() AS total
|
||||
FROM data.pools p
|
||||
@@ -289,6 +324,7 @@ WITH ins AS (
|
||||
)
|
||||
SELECT ins.id, ins.name, ins.notes, ins.metadata,
|
||||
ins.creator_id, u.name AS creator_name, ins.is_public,
|
||||
ins.sort_key, ins.sort_order,
|
||||
0 AS file_count
|
||||
FROM ins
|
||||
JOIN core.users u ON u.id = ins.creator_id`
|
||||
@@ -322,15 +358,18 @@ func (r *PoolRepo) Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*d
|
||||
const query = `
|
||||
WITH upd AS (
|
||||
UPDATE data.pools SET
|
||||
name = $2,
|
||||
notes = $3,
|
||||
metadata = COALESCE($4, metadata),
|
||||
is_public = $5
|
||||
name = $2,
|
||||
notes = $3,
|
||||
metadata = COALESCE($4, metadata),
|
||||
is_public = $5,
|
||||
sort_key = $6,
|
||||
sort_order = $7
|
||||
WHERE id = $1
|
||||
RETURNING *
|
||||
)
|
||||
SELECT upd.id, upd.name, upd.notes, upd.metadata,
|
||||
upd.creator_id, u.name AS creator_name, upd.is_public,
|
||||
upd.sort_key, upd.sort_order,
|
||||
COALESCE(fc.cnt, 0) AS file_count
|
||||
FROM upd
|
||||
JOIN core.users u ON u.id = upd.creator_id
|
||||
@@ -343,7 +382,7 @@ LEFT JOIN (SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool WHERE pool_id = $
|
||||
}
|
||||
|
||||
q := connOrTx(ctx, r.pool)
|
||||
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic)
|
||||
rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic, p.SortKey, p.SortOrder)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("PoolRepo.Update: %w", err)
|
||||
}
|
||||
@@ -412,8 +451,16 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor condition.
|
||||
var orderBy string
|
||||
// Resolve the pool's sort setting (defaulting to manual position order) into
|
||||
// the ORDER BY column, direction, and keyset comparison operator.
|
||||
sortKey := params.SortKey
|
||||
if !domain.ValidPoolSortKey(sortKey) {
|
||||
sortKey = domain.PoolSortManual
|
||||
}
|
||||
col, dir, cmp := poolFileSort(sortKey, params.SortOrder)
|
||||
|
||||
// Keyset cursor condition. For "created" the file id is both the sort key and
|
||||
// the tiebreak, so a single comparison suffices; the others compare (col, id).
|
||||
if params.Cursor != "" {
|
||||
cur, err := decodePoolCursor(params.Cursor)
|
||||
if err != nil {
|
||||
@@ -423,13 +470,36 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port.
|
||||
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
|
||||
if sortKey == domain.PoolSortCreated {
|
||||
conds = append(conds, fmt.Sprintf("f.id %s $%d", cmp, n))
|
||||
args = append(args, fileID)
|
||||
n++
|
||||
} else {
|
||||
conds = append(conds, fmt.Sprintf(
|
||||
"(%s %s $%d OR (%s = $%d AND f.id %s $%d))", col, cmp, n, col, n, cmp, n+1))
|
||||
switch sortKey {
|
||||
case domain.PoolSortContentDatetime:
|
||||
t, err := time.Parse(time.RFC3339Nano, cur.Val)
|
||||
if err != nil {
|
||||
return nil, domain.ErrValidation
|
||||
}
|
||||
args = append(args, t)
|
||||
case domain.PoolSortOriginalName:
|
||||
args = append(args, cur.Val)
|
||||
default: // manual
|
||||
args = append(args, cur.Pos)
|
||||
}
|
||||
args = append(args, fileID)
|
||||
n += 2
|
||||
}
|
||||
}
|
||||
|
||||
var orderBy string
|
||||
if sortKey == domain.PoolSortCreated {
|
||||
orderBy = fmt.Sprintf("f.id %s", dir)
|
||||
} else {
|
||||
orderBy = fmt.Sprintf("%s %s, f.id %s", col, dir, dir)
|
||||
}
|
||||
orderBy = "fp.position ASC, fp.file_id ASC"
|
||||
|
||||
where := "WHERE " + strings.Join(conds, " AND ")
|
||||
args = append(args, limit+1)
|
||||
@@ -468,11 +538,21 @@ LIMIT $%d`, fileSelectForPool, where, orderBy, n)
|
||||
|
||||
if hasMore && len(collected) > 0 {
|
||||
last := collected[len(collected)-1]
|
||||
cur := encodePoolCursor(poolFileCursor{
|
||||
Position: last.Position,
|
||||
FileID: last.ID.String(),
|
||||
})
|
||||
page.NextCursor = &cur
|
||||
cursor := poolFileCursor{FileID: last.ID.String()}
|
||||
switch sortKey {
|
||||
case domain.PoolSortContentDatetime:
|
||||
cursor.Val = last.ContentDatetime.UTC().Format(time.RFC3339Nano)
|
||||
case domain.PoolSortOriginalName:
|
||||
if last.OriginalName != nil {
|
||||
cursor.Val = *last.OriginalName
|
||||
}
|
||||
case domain.PoolSortCreated:
|
||||
// file id alone orders; nothing else to carry
|
||||
default: // manual
|
||||
cursor.Pos = last.Position
|
||||
}
|
||||
enc := encodePoolCursor(cursor)
|
||||
page.NextCursor = &enc
|
||||
}
|
||||
|
||||
// Batch-load tags.
|
||||
|
||||
@@ -7,6 +7,32 @@ import (
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Pool file sort keys. PoolSortManual keeps the user-defined order in
|
||||
// file_pool.position; the others sort the pool's files by that file field.
|
||||
const (
|
||||
PoolSortManual = "manual"
|
||||
PoolSortContentDatetime = "content_datetime"
|
||||
PoolSortCreated = "created"
|
||||
PoolSortOriginalName = "original_name"
|
||||
|
||||
SortOrderAsc = "asc"
|
||||
SortOrderDesc = "desc"
|
||||
)
|
||||
|
||||
// ValidPoolSortKey reports whether s is an accepted pool sort key.
|
||||
func ValidPoolSortKey(s string) bool {
|
||||
switch s {
|
||||
case PoolSortManual, PoolSortContentDatetime, PoolSortCreated, PoolSortOriginalName:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ValidSortOrder reports whether s is an accepted sort direction.
|
||||
func ValidSortOrder(s string) bool {
|
||||
return s == SortOrderAsc || s == SortOrderDesc
|
||||
}
|
||||
|
||||
// Pool is an ordered collection of files.
|
||||
type Pool struct {
|
||||
ID uuid.UUID
|
||||
@@ -16,6 +42,11 @@ type Pool struct {
|
||||
CreatorID int16
|
||||
CreatorName string // denormalized
|
||||
IsPublic bool
|
||||
// SortKey / SortOrder control how the pool's files are ordered. When SortKey
|
||||
// is PoolSortManual, files follow the manual position order and can be
|
||||
// reordered; otherwise they are sorted automatically and reordering is a no-op.
|
||||
SortKey string
|
||||
SortOrder string
|
||||
FileCount int
|
||||
CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ type poolJSON struct {
|
||||
CreatorID int16 `json:"creator_id"`
|
||||
CreatorName string `json:"creator_name"`
|
||||
IsPublic bool `json:"is_public"`
|
||||
SortKey string `json:"sort_key"`
|
||||
SortOrder string `json:"sort_order"`
|
||||
FileCount int `json:"file_count"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
@@ -51,6 +53,8 @@ func toPoolJSON(p domain.Pool) poolJSON {
|
||||
CreatorID: p.CreatorID,
|
||||
CreatorName: p.CreatorName,
|
||||
IsPublic: p.IsPublic,
|
||||
SortKey: p.SortKey,
|
||||
SortOrder: p.SortOrder,
|
||||
FileCount: p.FileCount,
|
||||
CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339),
|
||||
}
|
||||
@@ -214,6 +218,16 @@ func (h *PoolHandler) Update(c *gin.Context) {
|
||||
params.IsPublic = &b
|
||||
}
|
||||
}
|
||||
if v, ok := raw["sort_key"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
params.SortKey = &s
|
||||
}
|
||||
}
|
||||
if v, ok := raw["sort_order"]; ok {
|
||||
if s, ok := v.(string); ok {
|
||||
params.SortOrder = &s
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := h.poolSvc.Update(c.Request.Context(), id, params)
|
||||
if err != nil {
|
||||
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1745,3 +1746,115 @@ func TestDuplicateDetection(t *testing.T) {
|
||||
resp.decode(t, &list)
|
||||
assert.Equal(t, 0, list.Total, "dismissal must survive a rescan")
|
||||
}
|
||||
|
||||
// TestPoolAutomaticSort exercises per-pool file ordering: the default manual
|
||||
// order, switching to automatic sorts (by name and by creation), that an
|
||||
// auto-sorted pool rejects manual reordering, and that keyset paging honours the
|
||||
// active sort.
|
||||
func TestPoolAutomaticSort(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in short mode")
|
||||
}
|
||||
|
||||
h := setupSuite(t)
|
||||
admin := h.login("admin", "admin")
|
||||
|
||||
// Upload three files with distinct names in a non-alphabetical order.
|
||||
idB := h.uploadJPEG(admin, "b.jpg")["id"].(string)
|
||||
idA := h.uploadJPEG(admin, "a.jpg")["id"].(string)
|
||||
idC := h.uploadJPEG(admin, "c.jpg")["id"].(string)
|
||||
|
||||
// "created" order is UUID (byte) order — lexicographic on the canonical string.
|
||||
createdAsc := []string{idA, idB, idC}
|
||||
sort.Strings(createdAsc)
|
||||
createdDesc := []string{createdAsc[2], createdAsc[1], createdAsc[0]}
|
||||
|
||||
// New pool defaults to manual ordering.
|
||||
resp := h.doJSON("POST", "/pools", map[string]any{"name": "sortpool"}, admin)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
var pool map[string]any
|
||||
resp.decode(t, &pool)
|
||||
poolID := pool["id"].(string)
|
||||
require.Equal(t, "manual", pool["sort_key"])
|
||||
|
||||
// Add in upload order (b, a, c) → that becomes the manual position order.
|
||||
resp = h.doJSON("POST", "/pools/"+poolID+"/files",
|
||||
map[string]any{"file_ids": []string{idB, idA, idC}}, admin)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||
|
||||
listIDs := func() []string {
|
||||
resp := h.doJSON("GET", "/pools/"+poolID+"/files", nil, admin)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
var page struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
resp.decode(t, &page)
|
||||
ids := make([]string, len(page.Items))
|
||||
for i, it := range page.Items {
|
||||
ids[i] = it.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
setSort := func(key, order string) {
|
||||
body := map[string]any{"sort_key": key}
|
||||
if order != "" {
|
||||
body["sort_order"] = order
|
||||
}
|
||||
resp := h.doJSON("PATCH", "/pools/"+poolID, body, admin)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
}
|
||||
|
||||
// Manual = insertion order.
|
||||
require.Equal(t, []string{idB, idA, idC}, listIDs())
|
||||
|
||||
// By name ascending → a, b, c.
|
||||
setSort("original_name", "asc")
|
||||
require.Equal(t, []string{idA, idB, idC}, listIDs())
|
||||
|
||||
// By creation descending → reverse UUID order.
|
||||
setSort("created", "desc")
|
||||
require.Equal(t, createdDesc, listIDs())
|
||||
|
||||
// Reordering an auto-sorted pool is rejected.
|
||||
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder",
|
||||
map[string]any{"file_ids": []string{idC, idB, idA}}, admin)
|
||||
require.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.String())
|
||||
|
||||
// Back to manual → reordering works again and sticks.
|
||||
setSort("manual", "")
|
||||
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder",
|
||||
map[string]any{"file_ids": []string{idC, idB, idA}}, admin)
|
||||
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||
require.Equal(t, []string{idC, idB, idA}, listIDs())
|
||||
|
||||
// Keyset paging (limit 1) under an automatic sort returns the full sequence.
|
||||
setSort("original_name", "asc")
|
||||
var paged []string
|
||||
cursor := ""
|
||||
for i := 0; i < 10; i++ {
|
||||
url := "/pools/" + poolID + "/files?limit=1"
|
||||
if cursor != "" {
|
||||
url += "&cursor=" + cursor
|
||||
}
|
||||
resp := h.doJSON("GET", url, nil, admin)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||
var page struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
NextCursor *string `json:"next_cursor"`
|
||||
}
|
||||
resp.decode(t, &page)
|
||||
for _, it := range page.Items {
|
||||
paged = append(paged, it.ID)
|
||||
}
|
||||
if page.NextCursor == nil {
|
||||
break
|
||||
}
|
||||
cursor = *page.NextCursor
|
||||
}
|
||||
require.Equal(t, []string{idA, idB, idC}, paged)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ type PoolFileListParams struct {
|
||||
Cursor string
|
||||
Limit int
|
||||
Filter string // filter DSL expression
|
||||
// SortKey / SortOrder come from the pool itself (not the request) and select
|
||||
// how files are ordered: manual position order or an automatic file-field sort.
|
||||
SortKey string
|
||||
SortOrder string
|
||||
}
|
||||
|
||||
// FileRepo is the persistence interface for file records.
|
||||
|
||||
@@ -13,12 +13,15 @@ import (
|
||||
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.
|
||||
// PoolParams holds the fields for creating or patching a pool. SortKey and
|
||||
// SortOrder are pointers so a patch can leave them unchanged (nil) vs set them.
|
||||
type PoolParams struct {
|
||||
Name string
|
||||
Notes *string
|
||||
Metadata json.RawMessage
|
||||
IsPublic *bool
|
||||
Name string
|
||||
Notes *string
|
||||
Metadata json.RawMessage
|
||||
IsPublic *bool
|
||||
SortKey *string
|
||||
SortOrder *string
|
||||
}
|
||||
|
||||
// PoolService handles pool CRUD and pool–file management with ACL + audit.
|
||||
@@ -164,6 +167,18 @@ func (s *PoolService) Update(ctx context.Context, id uuid.UUID, p PoolParams) (*
|
||||
if p.IsPublic != nil {
|
||||
patch.IsPublic = *p.IsPublic
|
||||
}
|
||||
if p.SortKey != nil {
|
||||
if !domain.ValidPoolSortKey(*p.SortKey) {
|
||||
return nil, domain.ErrValidation
|
||||
}
|
||||
patch.SortKey = *p.SortKey
|
||||
}
|
||||
if p.SortOrder != nil {
|
||||
if !domain.ValidSortOrder(*p.SortOrder) {
|
||||
return nil, domain.ErrValidation
|
||||
}
|
||||
patch.SortOrder = *p.SortOrder
|
||||
}
|
||||
|
||||
updated, err := s.pools.Update(ctx, id, &patch)
|
||||
if err != nil {
|
||||
@@ -205,12 +220,24 @@ func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error {
|
||||
// Pool–file operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// ListFiles returns cursor-paginated files within a pool ordered by position,
|
||||
// enforcing view ACL on the pool.
|
||||
// ListFiles returns cursor-paginated files within a pool, enforcing view ACL.
|
||||
// The ordering is the pool's own stored sort setting (manual position order or
|
||||
// an automatic file-field sort), not a request parameter.
|
||||
func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) {
|
||||
if err := s.authorizeView(ctx, poolID); err != nil {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
pool, err := s.pools.GetByID(ctx, poolID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ok, err := s.acl.CanView(ctx, userID, isAdmin, pool.CreatorID, pool.IsPublic, poolObjectTypeID, poolID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, domain.ErrForbidden
|
||||
}
|
||||
params.SortKey = pool.SortKey
|
||||
params.SortOrder = pool.SortOrder
|
||||
return s.pools.ListFiles(ctx, poolID, params)
|
||||
}
|
||||
|
||||
@@ -242,11 +269,24 @@ func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs
|
||||
}
|
||||
|
||||
// Reorder sets the ordered sequence of file IDs within a pool, enforcing edit
|
||||
// ACL on the pool.
|
||||
// ACL on the pool. Manual ordering only applies when the pool's sort key is
|
||||
// "manual"; reordering an auto-sorted pool is rejected as a validation error.
|
||||
func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error {
|
||||
if err := s.authorizeEdit(ctx, poolID); err != nil {
|
||||
userID, isAdmin, _ := domain.UserFromContext(ctx)
|
||||
pool, err := s.pools.GetByID(ctx, poolID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok, err := s.acl.CanEdit(ctx, userID, isAdmin, pool.CreatorID, poolObjectTypeID, poolID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return domain.ErrForbidden
|
||||
}
|
||||
if pool.SortKey != domain.PoolSortManual {
|
||||
return domain.ErrValidation
|
||||
}
|
||||
if err := s.pools.Reorder(ctx, poolID, fileIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -76,8 +76,17 @@ CREATE TABLE data.pools (
|
||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||
is_public boolean NOT NULL DEFAULT false,
|
||||
-- File ordering within the pool. 'manual' keeps the user-defined order in
|
||||
-- data.file_pool.position (drag-to-reorder); any other key sorts the pool's
|
||||
-- files automatically by that file field, in which case reordering is disabled.
|
||||
sort_key varchar(32) NOT NULL DEFAULT 'manual',
|
||||
sort_order varchar(4) NOT NULL DEFAULT 'asc',
|
||||
|
||||
CONSTRAINT uni__pools__name UNIQUE (name)
|
||||
CONSTRAINT uni__pools__name UNIQUE (name),
|
||||
CONSTRAINT chk__pools__sort_key
|
||||
CHECK (sort_key IN ('manual', 'content_datetime', 'created', 'original_name')),
|
||||
CONSTRAINT chk__pools__sort_order
|
||||
CHECK (sort_order IN ('asc', 'desc'))
|
||||
);
|
||||
|
||||
-- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow
|
||||
|
||||
Reference in New Issue
Block a user