From 7d0ea4e388fde57cd0c6aa61f14aedcf4f6415ee Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 23:20:46 +0300 Subject: [PATCH] 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 --- backend/internal/db/postgres/pool_repo.go | 10 ++++++ backend/internal/handler/pool_handler.go | 19 ++++++++++++ backend/internal/handler/router.go | 1 + backend/internal/integration/server_test.go | 34 +++++++++++++++++++++ backend/internal/port/repository.go | 3 ++ backend/internal/service/pool_service.go | 10 ++++++ 6 files changed, 77 insertions(+) diff --git a/backend/internal/db/postgres/pool_repo.go b/backend/internal/db/postgres/pool_repo.go index c49ed99..2dcc810 100644 --- a/backend/internal/db/postgres/pool_repo.go +++ b/backend/internal/db/postgres/pool_repo.go @@ -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 // --------------------------------------------------------------------------- diff --git a/backend/internal/handler/pool_handler.go b/backend/internal/handler/pool_handler.go index c55593e..7ddd457 100644 --- a/backend/internal/handler/pool_handler.go +++ b/backend/internal/handler/pool_handler.go @@ -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 // --------------------------------------------------------------------------- diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go index addeec6..2679df6 100644 --- a/backend/internal/handler/router.go +++ b/backend/internal/handler/router.go @@ -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) diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index dcffdbf..a5819f6 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -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() { diff --git a/backend/internal/port/repository.go b/backend/internal/port/repository.go index 0b991b8..ab45c0d 100644 --- a/backend/internal/port/repository.go +++ b/backend/internal/port/repository.go @@ -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. diff --git a/backend/internal/service/pool_service.go b/backend/internal/service/pool_service.go index 850e26f..307175d 100644 --- a/backend/internal/service/pool_service.go +++ b/backend/internal/service/pool_service.go @@ -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 {