From a78fc5ba9ac003bbb2cdcd6e772c3bfe7765c5f3 Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Thu, 11 Jun 2026 14:49:14 +0300 Subject: [PATCH] feat(backend): log file views The activity.file_views table existed but nothing ever wrote to it. Add a POST /files/{id}/views endpoint: FileRepo.RecordView inserts a history row, FileService.RecordView enforces view ACL first. The file viewer fires it (fire-and-forget) when a file is opened, including while paging prev/next. Documented in openapi.yaml; covered by TestRecordFileView (204 on view, repeatable, 404 for unknown file). Co-Authored-By: Claude Opus 4.8 --- backend/internal/db/postgres/file_repo.go | 10 ++++++++ backend/internal/handler/file_handler.go | 19 ++++++++++++++ backend/internal/handler/router.go | 1 + backend/internal/integration/server_test.go | 25 +++++++++++++++++++ backend/internal/port/repository.go | 3 +++ backend/internal/service/file_service.go | 20 +++++++++++++++ .../src/lib/components/file/FileViewer.svelte | 3 +++ openapi.yaml | 12 +++++++++ 8 files changed, 93 insertions(+) diff --git a/backend/internal/db/postgres/file_repo.go b/backend/internal/db/postgres/file_repo.go index 816d90f..ab90ce6 100644 --- a/backend/internal/db/postgres/file_repo.go +++ b/backend/internal/db/postgres/file_repo.go @@ -801,3 +801,13 @@ func (r *FileRepo) loadTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[ } return result, nil } + +// RecordView appends a row to activity.file_views. viewed_at defaults to +// statement_timestamp(), so each call records a distinct view in the history. +func (r *FileRepo) RecordView(ctx context.Context, fileID uuid.UUID, userID int16) error { + const query = `INSERT INTO activity.file_views (file_id, user_id) VALUES ($1, $2)` + if _, err := connOrTx(ctx, r.pool).Exec(ctx, query, fileID, userID); err != nil { + return fmt.Errorf("FileRepo.RecordView: %w", err) + } + return nil +} diff --git a/backend/internal/handler/file_handler.go b/backend/internal/handler/file_handler.go index 9b3bb77..a71abc4 100644 --- a/backend/internal/handler/file_handler.go +++ b/backend/internal/handler/file_handler.go @@ -300,6 +300,25 @@ func (h *FileHandler) GetMeta(c *gin.Context) { respondJSON(c, http.StatusOK, toFileJSON(*f)) } +// --------------------------------------------------------------------------- +// POST /files/:id/views +// --------------------------------------------------------------------------- + +// RecordView logs that the current user viewed the file (activity.file_views). +func (h *FileHandler) RecordView(c *gin.Context) { + id, ok := parseFileID(c) + if !ok { + return + } + + if err := h.fileSvc.RecordView(c.Request.Context(), id); err != nil { + respondError(c, err) + return + } + + c.Status(http.StatusNoContent) +} + // --------------------------------------------------------------------------- // PATCH /files/:id // --------------------------------------------------------------------------- diff --git a/backend/internal/handler/router.go b/backend/internal/handler/router.go index 1dce3ea..addeec6 100644 --- a/backend/internal/handler/router.go +++ b/backend/internal/handler/router.go @@ -84,6 +84,7 @@ func NewRouter( files.PUT("/:id/content", fileHandler.ReplaceContent) files.GET("/:id/thumbnail", fileHandler.GetThumbnail) files.GET("/:id/preview", fileHandler.GetPreview) + files.POST("/:id/views", fileHandler.RecordView) files.POST("/:id/restore", fileHandler.Restore) files.DELETE("/:id/permanent", fileHandler.PermanentDelete) diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index 08326dd..80a8758 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -690,6 +690,31 @@ func TestTagAutoRule(t *testing.T) { assert.ElementsMatch(t, []string{"outdoor", "nature"}, names) } +// TestRecordFileView verifies that viewing a file is logged (POST .../views), +// is repeatable (view history, not a unique flag), and 404s for unknown files. +func TestRecordFileView(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + h := setupSuite(t) + adminToken := h.login("admin", "admin") + + file := h.uploadJPEG(adminToken, "seen.jpg") + fileID := file["id"].(string) + + resp := h.doJSON("POST", "/files/"+fileID+"/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", "/files/"+fileID+"/views", nil, adminToken) + require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String()) + + // Unknown file id → 404. + resp = h.doJSON("POST", "/files/00000000-0000-0000-0000-000000000000/views", nil, adminToken) + require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String()) +} + // --------------------------------------------------------------------------- // Security regression tests // --------------------------------------------------------------------------- diff --git a/backend/internal/port/repository.go b/backend/internal/port/repository.go index e578aa2..94f6d8d 100644 --- a/backend/internal/port/repository.go +++ b/backend/internal/port/repository.go @@ -59,6 +59,9 @@ type FileRepo interface { ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) // SetTags replaces all tags on a file (full replace semantics). SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error + + // RecordView appends a view-history row (activity.file_views) for the user. + RecordView(ctx context.Context, fileID uuid.UUID, userID int16) error } // TagRepo is the persistence interface for tags. diff --git a/backend/internal/service/file_service.go b/backend/internal/service/file_service.go index 35ba5ed..1c28002 100644 --- a/backend/internal/service/file_service.go +++ b/backend/internal/service/file_service.go @@ -208,6 +208,26 @@ func (s *FileService) Get(ctx context.Context, id uuid.UUID) (*domain.File, erro return f, nil } +// RecordView appends a view-history entry for the current user, enforcing view +// ACL (you can only record a view of a file you may see). +func (s *FileService) RecordView(ctx context.Context, id uuid.UUID) error { + userID, isAdmin, _ := domain.UserFromContext(ctx) + + f, err := s.files.GetByID(ctx, id) + if err != nil { + return err + } + + ok, err := s.acl.CanView(ctx, userID, isAdmin, f.CreatorID, f.IsPublic, fileObjectTypeID, id) + if err != nil { + return err + } + if !ok { + return domain.ErrForbidden + } + return s.files.RecordView(ctx, id, userID) +} + // Update applies metadata changes to a file, enforcing edit ACL. func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams) (*domain.File, error) { userID, isAdmin, _ := domain.UserFromContext(ctx) diff --git a/frontend/src/lib/components/file/FileViewer.svelte b/frontend/src/lib/components/file/FileViewer.svelte index 85d01ef..eb31787 100644 --- a/frontend/src/lib/components/file/FileViewer.svelte +++ b/frontend/src/lib/components/file/FileViewer.svelte @@ -77,6 +77,9 @@ isPublic = fileData.is_public ?? false; dirty = false; void fetchPreview(id); + // Log the view (activity.file_views). Fire-and-forget — never block or + // fail the viewer over view tracking. + void api.post(`/files/${id}/views`).catch(() => {}); } catch (e) { error = e instanceof ApiError ? e.message : 'Failed to load file'; } finally { diff --git a/openapi.yaml b/openapi.yaml index 9fce40b..ff30b50 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -407,6 +407,18 @@ paths: '404': $ref: '#/components/responses/NotFound' + /files/{file_id}/views: + post: + tags: [Files] + summary: Record that the current user viewed the file + parameters: + - $ref: '#/components/parameters/file_id' + responses: + '204': + description: View recorded + '404': + $ref: '#/components/responses/NotFound' + /files/{file_id}/restore: post: tags: [Files]