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
|
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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user