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:
@@ -266,6 +266,16 @@ WHERE p.id = $1`
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -160,6 +160,25 @@ func (h *PoolHandler) Get(c *gin.Context) {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -141,6 +141,7 @@ func NewRouter(
|
||||
pools.GET("/:pool_id", poolHandler.Get)
|
||||
pools.PATCH("/:pool_id", poolHandler.Update)
|
||||
pools.DELETE("/:pool_id", poolHandler.Delete)
|
||||
pools.POST("/:pool_id/views", poolHandler.RecordView)
|
||||
|
||||
// Sub-routes registered before /:pool_id/files to avoid param conflicts.
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func TestBulkTagAutoRule(t *testing.T) {
|
||||
if testing.Short() {
|
||||
|
||||
@@ -129,6 +129,9 @@ type PoolRepo interface {
|
||||
RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error
|
||||
// Reorder sets the full ordered sequence of file IDs in the pool.
|
||||
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.
|
||||
|
||||
@@ -82,6 +82,16 @@ func (s *PoolService) authorizeView(ctx context.Context, poolID uuid.UUID) error
|
||||
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
|
||||
// (or ErrNotFound if the pool does not exist).
|
||||
func (s *PoolService) authorizeEdit(ctx context.Context, poolID uuid.UUID) error {
|
||||
|
||||
Reference in New Issue
Block a user