feat(backend): log file views
deploy / deploy (push) Successful in 1m0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 14:49:14 +03:00
parent 8f213e780c
commit a78fc5ba9a
8 changed files with 93 additions and 0 deletions
+10
View File
@@ -801,3 +801,13 @@ func (r *FileRepo) loadTagsBatch(ctx context.Context, fileIDs []uuid.UUID) (map[
} }
return result, nil 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
}
+19
View File
@@ -300,6 +300,25 @@ func (h *FileHandler) GetMeta(c *gin.Context) {
respondJSON(c, http.StatusOK, toFileJSON(*f)) 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 // PATCH /files/:id
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+1
View File
@@ -84,6 +84,7 @@ func NewRouter(
files.PUT("/:id/content", fileHandler.ReplaceContent) files.PUT("/:id/content", fileHandler.ReplaceContent)
files.GET("/:id/thumbnail", fileHandler.GetThumbnail) files.GET("/:id/thumbnail", fileHandler.GetThumbnail)
files.GET("/:id/preview", fileHandler.GetPreview) files.GET("/:id/preview", fileHandler.GetPreview)
files.POST("/:id/views", fileHandler.RecordView)
files.POST("/:id/restore", fileHandler.Restore) files.POST("/:id/restore", fileHandler.Restore)
files.DELETE("/:id/permanent", fileHandler.PermanentDelete) files.DELETE("/:id/permanent", fileHandler.PermanentDelete)
@@ -690,6 +690,31 @@ func TestTagAutoRule(t *testing.T) {
assert.ElementsMatch(t, []string{"outdoor", "nature"}, names) 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 // Security regression tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+3
View File
@@ -59,6 +59,9 @@ type FileRepo interface {
ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error) ListTags(ctx context.Context, fileID uuid.UUID) ([]domain.Tag, error)
// SetTags replaces all tags on a file (full replace semantics). // SetTags replaces all tags on a file (full replace semantics).
SetTags(ctx context.Context, fileID uuid.UUID, tagIDs []uuid.UUID) error 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. // TagRepo is the persistence interface for tags.
+20
View File
@@ -208,6 +208,26 @@ func (s *FileService) Get(ctx context.Context, id uuid.UUID) (*domain.File, erro
return f, nil 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. // 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) { func (s *FileService) Update(ctx context.Context, id uuid.UUID, p UpdateParams) (*domain.File, error) {
userID, isAdmin, _ := domain.UserFromContext(ctx) userID, isAdmin, _ := domain.UserFromContext(ctx)
@@ -77,6 +77,9 @@
isPublic = fileData.is_public ?? false; isPublic = fileData.is_public ?? false;
dirty = false; dirty = false;
void fetchPreview(id); 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) { } catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to load file'; error = e instanceof ApiError ? e.message : 'Failed to load file';
} finally { } finally {
+12
View File
@@ -407,6 +407,18 @@ paths:
'404': '404':
$ref: '#/components/responses/NotFound' $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: /files/{file_id}/restore:
post: post:
tags: [Files] tags: [Files]