feat(backend): record pool views to activity.pool_views

Add POST /pools/{id}/views, mirroring the file-view endpoint: it
enforces view ACL and appends a row to activity.pool_views (viewed_at
defaults to statement_timestamp(), so each view is its own history row).
The table existed but nothing wrote to it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 23:20:46 +03:00
parent 38572b1c80
commit 7d0ea4e388
6 changed files with 77 additions and 0 deletions
+10
View File
@@ -266,6 +266,16 @@ WHERE p.id = $1`
return &p, nil return &p, nil
} }
// RecordView appends a row to activity.pool_views. viewed_at defaults to
// statement_timestamp(), so each call records a distinct view in the history.
func (r *PoolRepo) RecordView(ctx context.Context, poolID uuid.UUID, userID int16) error {
const query = `INSERT INTO activity.pool_views (pool_id, user_id) VALUES ($1, $2)`
if _, err := connOrTx(ctx, r.pool).Exec(ctx, query, poolID, userID); err != nil {
return fmt.Errorf("PoolRepo.RecordView: %w", err)
}
return nil
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Create // Create
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+19
View File
@@ -160,6 +160,25 @@ func (h *PoolHandler) Get(c *gin.Context) {
respondJSON(c, http.StatusOK, toPoolJSON(*p)) respondJSON(c, http.StatusOK, toPoolJSON(*p))
} }
// ---------------------------------------------------------------------------
// POST /pools/:pool_id/views
// ---------------------------------------------------------------------------
// RecordView logs that the current user viewed the pool (activity.pool_views).
func (h *PoolHandler) RecordView(c *gin.Context) {
id, ok := parsePoolID(c)
if !ok {
return
}
if err := h.poolSvc.RecordView(c.Request.Context(), id); err != nil {
respondError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// PATCH /pools/:pool_id // PATCH /pools/:pool_id
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1
View File
@@ -141,6 +141,7 @@ func NewRouter(
pools.GET("/:pool_id", poolHandler.Get) pools.GET("/:pool_id", poolHandler.Get)
pools.PATCH("/:pool_id", poolHandler.Update) pools.PATCH("/:pool_id", poolHandler.Update)
pools.DELETE("/:pool_id", poolHandler.Delete) pools.DELETE("/:pool_id", poolHandler.Delete)
pools.POST("/:pool_id/views", poolHandler.RecordView)
// Sub-routes registered before /:pool_id/files to avoid param conflicts. // Sub-routes registered before /:pool_id/files to avoid param conflicts.
pools.POST("/:pool_id/files/remove", poolHandler.RemoveFiles) pools.POST("/:pool_id/files/remove", poolHandler.RemoveFiles)
@@ -857,6 +857,40 @@ func TestTagSortByCategoryThenName(t *testing.T) {
assert.Equal(t, []string{"ant", "zebra", "mid", "solo"}, names) assert.Equal(t, []string{"ant", "zebra", "mid", "solo"}, names)
} }
// TestRecordPoolView verifies that viewing a pool is logged (POST .../views),
// is repeatable (view history, not a unique flag), and 404s for unknown pools.
func TestRecordPoolView(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
ctx := context.Background()
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/pools", map[string]any{"name": "trip"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var pool map[string]any
resp.decode(t, &pool)
poolID := pool["id"].(string)
resp = h.doJSON("POST", "/pools/"+poolID+"/views", nil, adminToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// Viewing again logs another history row, not a conflict.
resp = h.doJSON("POST", "/pools/"+poolID+"/views", nil, adminToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
var n int
require.NoError(t, h.pool.QueryRow(ctx,
`SELECT count(*) FROM activity.pool_views WHERE pool_id = $1`, poolID).Scan(&n))
assert.Equal(t, 2, n, "each view should add a history row")
// Unknown pool id → 404.
resp = h.doJSON("POST", "/pools/00000000-0000-0000-0000-000000000000/views", nil, adminToken)
require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
}
// TestBulkTagAutoRule verifies the bulk add path also applies then_tags. // TestBulkTagAutoRule verifies the bulk add path also applies then_tags.
func TestBulkTagAutoRule(t *testing.T) { func TestBulkTagAutoRule(t *testing.T) {
if testing.Short() { if testing.Short() {
+3
View File
@@ -129,6 +129,9 @@ type PoolRepo interface {
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
// Reorder sets the full ordered sequence of file IDs in the pool. // Reorder sets the full ordered sequence of file IDs in the pool.
Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
// RecordView appends a view-history row (activity.pool_views) for the user.
RecordView(ctx context.Context, poolID uuid.UUID, userID int16) error
} }
// UserRepo is the persistence interface for users. // UserRepo is the persistence interface for users.
+10
View File
@@ -82,6 +82,16 @@ func (s *PoolService) authorizeView(ctx context.Context, poolID uuid.UUID) error
return nil return nil
} }
// RecordView appends a view-history entry for the current user, enforcing view
// ACL (you can only record a view of a pool you may see).
func (s *PoolService) RecordView(ctx context.Context, id uuid.UUID) error {
userID, _, _ := domain.UserFromContext(ctx)
if err := s.authorizeView(ctx, id); err != nil {
return err
}
return s.pools.RecordView(ctx, id, userID)
}
// authorizeEdit returns nil if the caller may edit the pool, else ErrForbidden // authorizeEdit returns nil if the caller may edit the pool, else ErrForbidden
// (or ErrNotFound if the pool does not exist). // (or ErrNotFound if the pool does not exist).
func (s *PoolService) authorizeEdit(ctx context.Context, poolID uuid.UUID) error { func (s *PoolService) authorizeEdit(ctx context.Context, poolID uuid.UUID) error {