From 51c194317a50babdae87e5e7af0ccdbd3920c355 Mon Sep 17 00:00:00 2001 From: david <58231312+davseby@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:33:43 +0300 Subject: [PATCH] track document views --- .../core/internal/db/document_branch_view.go | 63 ++++++ .../internal/db/document_branch_view_test.go | 200 ++++++++++++++++++ .../migrations/006_document_branch_views.sql | 16 ++ server/core/internal/server/0mock_db_test.go | 72 +++++++ .../server/internal/document/0mock_db_test.go | 72 +++++++ .../server/internal/document/0mock_tx_test.go | 72 +++++++ .../server/internal/document/branch_http.go | 51 +++++ .../internal/document/branch_http_test.go | 98 +++++++++ .../server/internal/document/document_http.go | 11 + server/core/internal/server/router.go | 1 + .../api/useDocumentAPI.nuxt.test.ts | 30 +++ web/app/composables/api/useDocumentAPI.ts | 13 ++ .../[[organizationSlug]]/[[documentSlug]].vue | 31 +++ 13 files changed, 730 insertions(+) create mode 100644 server/core/internal/db/document_branch_view.go create mode 100644 server/core/internal/db/document_branch_view_test.go create mode 100644 server/core/internal/db/migrations/006_document_branch_views.sql diff --git a/server/core/internal/db/document_branch_view.go b/server/core/internal/db/document_branch_view.go new file mode 100644 index 00000000..3e645136 --- /dev/null +++ b/server/core/internal/db/document_branch_view.go @@ -0,0 +1,63 @@ +package db + +import ( + "context" + "time" + + sq "github.com/Masterminds/squirrel" + "github.com/jmoiron/sqlx" + "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/rs/xid" +) + +// UpsertDocumentBranchView records that the user viewed the branch at the +// given time. A branch viewed before keeps one row with the newer time. +func (a *agent) UpsertDocumentBranchView( + ctx context.Context, + userID string, + organizationID string, + branchID xid.ID, + viewedAt time.Time, +) error { + q, args := a.builder.Insert("document_branch_views"). + SetMap(map[string]any{ + "fk_user_id": userID, + "fk_organization_id": organizationID, + "fk_branch_id": branchID, + "viewed_at": viewedAt, + }). + Suffix("ON CONFLICT (fk_user_id, fk_branch_id) DO UPDATE SET viewed_at = EXCLUDED.viewed_at"). + MustSql() + + _, err := a.sql.ExecContext(ctx, q, args...) + + return err +} + +// FetchRecentlyViewedDocuments fetches up to limit branches the user viewed +// in the organization, joined against their document, most recent first. +func (a *agent) FetchRecentlyViewedDocuments( + ctx context.Context, + userID string, + organizationID string, + limit int, +) ([]document.Document, error) { + q, args := a.selectDocumentBranch(a.builder.Select()). + Join("document_branch_views dbv ON dbv.fk_branch_id = db.id"). + Where(sq.Eq{ + "dbv.fk_user_id": userID, + "dbv.fk_organization_id": organizationID, + "documents.fk_organization_id": organizationID, + }). + OrderBy("dbv.viewed_at DESC", "db.id DESC"). + Limit(uint64(limit)). + MustSql() + + docs := []document.Document{} + + if err := sqlx.SelectContext(ctx, a.sql, &docs, q, args...); err != nil { + return nil, err + } + + return docs, nil +} diff --git a/server/core/internal/db/document_branch_view_test.go b/server/core/internal/db/document_branch_view_test.go new file mode 100644 index 00000000..b273c5ad --- /dev/null +++ b/server/core/internal/db/document_branch_view_test.go @@ -0,0 +1,200 @@ +package db + +import ( + "context" + "testing" + "time" + + "github.com/oxynote/oxynote/server/core/internal/document" + "github.com/oxynote/oxynote/server/core/pkg/testutil" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" + "github.com/rs/xid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_agent_UpsertDocumentBranchView(t *testing.T) { + type tcase struct { + UserID string + OrganizationID string + BranchID xid.ID + ViewedAt time.Time + Result []document.Document + Err error + } + + now := timeutil.Now().Truncate(time.Second) + + cc := map[string]func(*testing.T, *DB) tcase{ + "Non-existent branch": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + + return tcase{ + UserID: prepUsers(t, db, 1)[0], + OrganizationID: doc.OrganizationID, + BranchID: xid.New(), + ViewedAt: now, + Err: assert.AnError, + } + }, + "Successful insert": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + + return tcase{ + UserID: prepUsers(t, db, 1)[0], + OrganizationID: doc.OrganizationID, + BranchID: doc.BranchID, + ViewedAt: now, + Result: []document.Document{*doc}, + } + }, + "Successful update of an earlier view": func(t *testing.T, db *DB) tcase { + docs := prepDocumentBranches(t, db, 2, nil) + user := prepUsers(t, db, 1)[0] + + for i, doc := range docs { + require.NoError(t, db.UpsertDocumentBranchView( + context.Background(), + user, + doc.OrganizationID, + doc.BranchID, + now.Add(-time.Duration(len(docs)-i)*time.Hour), + )) + } + + return tcase{ + UserID: user, + OrganizationID: docs[0].OrganizationID, + BranchID: docs[0].BranchID, + ViewedAt: now, + Result: []document.Document{*docs[0], *docs[1]}, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + err := db.UpsertDocumentBranchView(context.Background(), c.UserID, c.OrganizationID, c.BranchID, c.ViewedAt) + testutil.RequireEqualError(t, c.Err, err) + + if err != nil { + return + } + + res, err := db.FetchRecentlyViewedDocuments(context.Background(), c.UserID, c.OrganizationID, 10) + require.NoError(t, err) + testutil.AssertFilterEqual(t, c.Result, res) + }) + } +} + +func Test_agent_FetchRecentlyViewedDocuments(t *testing.T) { + type tcase struct { + UserID string + OrganizationID string + Limit int + Result []document.Document + } + + now := timeutil.Now().Truncate(time.Second) + + // view records the user's views of the documents, the first one + // viewed most recently. + view := func(t *testing.T, db *DB, user string, docs ...*document.Document) { + t.Helper() + + for i, doc := range docs { + require.NoError(t, db.UpsertDocumentBranchView( + context.Background(), + user, + doc.OrganizationID, + doc.BranchID, + now.Add(-time.Duration(i)*time.Minute), + )) + } + } + + cc := map[string]func(*testing.T, *DB) tcase{ + "No views": func(t *testing.T, db *DB) tcase { + doc := prepDocuments(t, db, 1, nil)[0] + + return tcase{ + UserID: prepUsers(t, db, 1)[0], + OrganizationID: doc.OrganizationID, + Limit: 10, + Result: []document.Document{}, + } + }, + "Most recent first within the limit": func(t *testing.T, db *DB) tcase { + docs := prepDocumentBranches(t, db, 3, nil) + user := prepUsers(t, db, 1)[0] + + view(t, db, user, docs[2], docs[0], docs[1]) + + return tcase{ + UserID: user, + OrganizationID: docs[0].OrganizationID, + Limit: 2, + Result: []document.Document{*docs[2], *docs[0]}, + } + }, + "Views of other users and organizations are left out": func(t *testing.T, db *DB) tcase { + docs := prepDocumentBranches(t, db, 1, nil) + other := prepDocuments(t, db, 1, nil)[0] + users := prepUsers(t, db, 2) + + view(t, db, users[0], docs[0], other) + view(t, db, users[1], docs[0]) + + return tcase{ + UserID: users[0], + OrganizationID: docs[0].OrganizationID, + Limit: 10, + Result: []document.Document{*docs[0]}, + } + }, + "Views of deleted branches are gone": func(t *testing.T, db *DB) tcase { + docs := prepDocumentBranches(t, db, 2, nil) + user := prepUsers(t, db, 1)[0] + + view(t, db, user, docs[0], docs[1]) + + require.NoError(t, db.DeleteDocumentBranchByID(context.Background(), docs[0].BranchID, docs[0].OrganizationID)) + + return tcase{ + UserID: user, + OrganizationID: docs[0].OrganizationID, + Limit: 10, + Result: []document.Document{*docs[1]}, + } + }, + } + + for cn, cfn := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + db := prepTempDB(t) + c := cfn(t, db) + + res, err := db.FetchRecentlyViewedDocuments(context.Background(), c.UserID, c.OrganizationID, c.Limit) + require.NoError(t, err) + testutil.AssertFilterEqual(t, c.Result, res) + }) + } + + // error - cancelled context + db := prepTempDB(t) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + res, err := db.FetchRecentlyViewedDocuments(ctx, "user", "org", 10) + require.Error(t, err) + assert.Nil(t, res) +} diff --git a/server/core/internal/db/migrations/006_document_branch_views.sql b/server/core/internal/db/migrations/006_document_branch_views.sql new file mode 100644 index 00000000..c7d1ee98 --- /dev/null +++ b/server/core/internal/db/migrations/006_document_branch_views.sql @@ -0,0 +1,16 @@ +-- +migrate Up + +CREATE TABLE document_branch_views ( + fk_user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE, + fk_organization_id TEXT NOT NULL REFERENCES organizations ON DELETE CASCADE, + fk_branch_id TEXT NOT NULL REFERENCES document_branches(id) ON DELETE CASCADE, + viewed_at TIMESTAMP NOT NULL, + PRIMARY KEY (fk_user_id, fk_branch_id) +); +CREATE INDEX document_branch_views_fk_user_id_viewed_at_idx ON document_branch_views (fk_user_id, fk_organization_id, viewed_at DESC); +CREATE INDEX document_branch_views_fk_organization_id_idx ON document_branch_views (fk_organization_id); +CREATE INDEX document_branch_views_fk_branch_id_idx ON document_branch_views (fk_branch_id); + +-- +migrate Down + +DROP TABLE document_branch_views; diff --git a/server/core/internal/server/0mock_db_test.go b/server/core/internal/server/0mock_db_test.go index 8dbdb4d6..2421c5ca 100644 --- a/server/core/internal/server/0mock_db_test.go +++ b/server/core/internal/server/0mock_db_test.go @@ -6,6 +6,7 @@ package server import ( "context" "sync" + "time" "github.com/guregu/null/v5" slackCore "github.com/oxynote/oxynote/server/core/internal/apps/slack" @@ -298,6 +299,9 @@ var _ DB = &DBMock{} // UpdateUserImageFunc: func(ctx context.Context, userID string, image string) error { // panic("mock out the UpdateUserImage method") // }, +// UpsertDocumentBranchViewFunc: func(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error { +// panic("mock out the UpsertDocumentBranchView method") +// }, // UpsertDocumentMaintainersFunc: func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error { // panic("mock out the UpsertDocumentMaintainers method") // }, @@ -575,6 +579,9 @@ type DBMock struct { // UpdateUserImageFunc mocks the UpdateUserImage method. UpdateUserImageFunc func(ctx context.Context, userID string, image string) error + // UpsertDocumentBranchViewFunc mocks the UpsertDocumentBranchView method. + UpsertDocumentBranchViewFunc func(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error + // UpsertDocumentMaintainersFunc mocks the UpsertDocumentMaintainers method. UpsertDocumentMaintainersFunc func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error @@ -1349,6 +1356,19 @@ type DBMock struct { // Image is the image argument value. Image string } + // UpsertDocumentBranchView holds details about calls to the UpsertDocumentBranchView method. + UpsertDocumentBranchView []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // UserID is the userID argument value. + UserID string + // OrganizationID is the organizationID argument value. + OrganizationID string + // BranchID is the branchID argument value. + BranchID xid.ID + // ViewedAt is the viewedAt argument value. + ViewedAt time.Time + } // UpsertDocumentMaintainers holds details about calls to the UpsertDocumentMaintainers method. UpsertDocumentMaintainers []struct { // Ctx is the ctx argument value. @@ -1450,6 +1470,7 @@ type DBMock struct { lockUpdateSlackUserLink sync.RWMutex lockUpdateTagTree sync.RWMutex lockUpdateUserImage sync.RWMutex + lockUpsertDocumentBranchView sync.RWMutex lockUpsertDocumentMaintainers sync.RWMutex } @@ -5251,6 +5272,57 @@ func (mock *DBMock) UpdateUserImageCalls() []struct { return calls } +// UpsertDocumentBranchView calls UpsertDocumentBranchViewFunc. +func (mock *DBMock) UpsertDocumentBranchView(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error { + callInfo := struct { + Ctx context.Context + UserID string + OrganizationID string + BranchID xid.ID + ViewedAt time.Time + }{ + Ctx: ctx, + UserID: userID, + OrganizationID: organizationID, + BranchID: branchID, + ViewedAt: viewedAt, + } + mock.lockUpsertDocumentBranchView.Lock() + mock.calls.UpsertDocumentBranchView = append(mock.calls.UpsertDocumentBranchView, callInfo) + mock.lockUpsertDocumentBranchView.Unlock() + if mock.UpsertDocumentBranchViewFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.UpsertDocumentBranchViewFunc(ctx, userID, organizationID, branchID, viewedAt) +} + +// UpsertDocumentBranchViewCalls gets all the calls that were made to UpsertDocumentBranchView. +// Check the length with: +// +// len(mockedDB.UpsertDocumentBranchViewCalls()) +func (mock *DBMock) UpsertDocumentBranchViewCalls() []struct { + Ctx context.Context + UserID string + OrganizationID string + BranchID xid.ID + ViewedAt time.Time +} { + var calls []struct { + Ctx context.Context + UserID string + OrganizationID string + BranchID xid.ID + ViewedAt time.Time + } + mock.lockUpsertDocumentBranchView.RLock() + calls = mock.calls.UpsertDocumentBranchView + mock.lockUpsertDocumentBranchView.RUnlock() + return calls +} + // UpsertDocumentMaintainers calls UpsertDocumentMaintainersFunc. func (mock *DBMock) UpsertDocumentMaintainers(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error { callInfo := struct { diff --git a/server/core/internal/server/internal/document/0mock_db_test.go b/server/core/internal/server/internal/document/0mock_db_test.go index eb53b355..c5f160ef 100644 --- a/server/core/internal/server/internal/document/0mock_db_test.go +++ b/server/core/internal/server/internal/document/0mock_db_test.go @@ -6,6 +6,7 @@ package document import ( "context" "sync" + "time" "github.com/guregu/null/v5" documentCore "github.com/oxynote/oxynote/server/core/internal/document" @@ -136,6 +137,9 @@ var _ DB = &DBMock{} // UpdateDocumentTreeFunc: func(ctx context.Context, ss documentCore.Summaries, organizationID string) error { // panic("mock out the UpdateDocumentTree method") // }, +// UpsertDocumentBranchViewFunc: func(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error { +// panic("mock out the UpsertDocumentBranchView method") +// }, // UpsertDocumentMaintainersFunc: func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error { // panic("mock out the UpsertDocumentMaintainers method") // }, @@ -257,6 +261,9 @@ type DBMock struct { // UpdateDocumentTreeFunc mocks the UpdateDocumentTree method. UpdateDocumentTreeFunc func(ctx context.Context, ss documentCore.Summaries, organizationID string) error + // UpsertDocumentBranchViewFunc mocks the UpsertDocumentBranchView method. + UpsertDocumentBranchViewFunc func(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error + // UpsertDocumentMaintainersFunc mocks the UpsertDocumentMaintainers method. UpsertDocumentMaintainersFunc func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error @@ -589,6 +596,19 @@ type DBMock struct { // OrganizationID is the organizationID argument value. OrganizationID string } + // UpsertDocumentBranchView holds details about calls to the UpsertDocumentBranchView method. + UpsertDocumentBranchView []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // UserID is the userID argument value. + UserID string + // OrganizationID is the organizationID argument value. + OrganizationID string + // BranchID is the branchID argument value. + BranchID xid.ID + // ViewedAt is the viewedAt argument value. + ViewedAt time.Time + } // UpsertDocumentMaintainers holds details about calls to the UpsertDocumentMaintainers method. UpsertDocumentMaintainers []struct { // Ctx is the ctx argument value. @@ -638,6 +658,7 @@ type DBMock struct { lockUpdateDocumentBranchMetadata sync.RWMutex lockUpdateDocumentParentID sync.RWMutex lockUpdateDocumentTree sync.RWMutex + lockUpsertDocumentBranchView sync.RWMutex lockUpsertDocumentMaintainers sync.RWMutex } @@ -2236,6 +2257,57 @@ func (mock *DBMock) UpdateDocumentTreeCalls() []struct { return calls } +// UpsertDocumentBranchView calls UpsertDocumentBranchViewFunc. +func (mock *DBMock) UpsertDocumentBranchView(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error { + callInfo := struct { + Ctx context.Context + UserID string + OrganizationID string + BranchID xid.ID + ViewedAt time.Time + }{ + Ctx: ctx, + UserID: userID, + OrganizationID: organizationID, + BranchID: branchID, + ViewedAt: viewedAt, + } + mock.lockUpsertDocumentBranchView.Lock() + mock.calls.UpsertDocumentBranchView = append(mock.calls.UpsertDocumentBranchView, callInfo) + mock.lockUpsertDocumentBranchView.Unlock() + if mock.UpsertDocumentBranchViewFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.UpsertDocumentBranchViewFunc(ctx, userID, organizationID, branchID, viewedAt) +} + +// UpsertDocumentBranchViewCalls gets all the calls that were made to UpsertDocumentBranchView. +// Check the length with: +// +// len(mockedDB.UpsertDocumentBranchViewCalls()) +func (mock *DBMock) UpsertDocumentBranchViewCalls() []struct { + Ctx context.Context + UserID string + OrganizationID string + BranchID xid.ID + ViewedAt time.Time +} { + var calls []struct { + Ctx context.Context + UserID string + OrganizationID string + BranchID xid.ID + ViewedAt time.Time + } + mock.lockUpsertDocumentBranchView.RLock() + calls = mock.calls.UpsertDocumentBranchView + mock.lockUpsertDocumentBranchView.RUnlock() + return calls +} + // UpsertDocumentMaintainers calls UpsertDocumentMaintainersFunc. func (mock *DBMock) UpsertDocumentMaintainers(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error { callInfo := struct { diff --git a/server/core/internal/server/internal/document/0mock_tx_test.go b/server/core/internal/server/internal/document/0mock_tx_test.go index f8861715..03555dcc 100644 --- a/server/core/internal/server/internal/document/0mock_tx_test.go +++ b/server/core/internal/server/internal/document/0mock_tx_test.go @@ -6,6 +6,7 @@ package document import ( "context" "sync" + "time" "github.com/guregu/null/v5" documentCore "github.com/oxynote/oxynote/server/core/internal/document" @@ -139,6 +140,9 @@ var _ Tx = &TxMock{} // UpdateDocumentTreeFunc: func(ctx context.Context, ss documentCore.Summaries, organizationID string) error { // panic("mock out the UpdateDocumentTree method") // }, +// UpsertDocumentBranchViewFunc: func(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error { +// panic("mock out the UpsertDocumentBranchView method") +// }, // UpsertDocumentMaintainersFunc: func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error { // panic("mock out the UpsertDocumentMaintainers method") // }, @@ -263,6 +267,9 @@ type TxMock struct { // UpdateDocumentTreeFunc mocks the UpdateDocumentTree method. UpdateDocumentTreeFunc func(ctx context.Context, ss documentCore.Summaries, organizationID string) error + // UpsertDocumentBranchViewFunc mocks the UpsertDocumentBranchView method. + UpsertDocumentBranchViewFunc func(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error + // UpsertDocumentMaintainersFunc mocks the UpsertDocumentMaintainers method. UpsertDocumentMaintainersFunc func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error @@ -594,6 +601,19 @@ type TxMock struct { // OrganizationID is the organizationID argument value. OrganizationID string } + // UpsertDocumentBranchView holds details about calls to the UpsertDocumentBranchView method. + UpsertDocumentBranchView []struct { + // Ctx is the ctx argument value. + Ctx context.Context + // UserID is the userID argument value. + UserID string + // OrganizationID is the organizationID argument value. + OrganizationID string + // BranchID is the branchID argument value. + BranchID xid.ID + // ViewedAt is the viewedAt argument value. + ViewedAt time.Time + } // UpsertDocumentMaintainers holds details about calls to the UpsertDocumentMaintainers method. UpsertDocumentMaintainers []struct { // Ctx is the ctx argument value. @@ -644,6 +664,7 @@ type TxMock struct { lockUpdateDocumentBranchMetadata sync.RWMutex lockUpdateDocumentParentID sync.RWMutex lockUpdateDocumentTree sync.RWMutex + lockUpsertDocumentBranchView sync.RWMutex lockUpsertDocumentMaintainers sync.RWMutex } @@ -2263,6 +2284,57 @@ func (mock *TxMock) UpdateDocumentTreeCalls() []struct { return calls } +// UpsertDocumentBranchView calls UpsertDocumentBranchViewFunc. +func (mock *TxMock) UpsertDocumentBranchView(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error { + callInfo := struct { + Ctx context.Context + UserID string + OrganizationID string + BranchID xid.ID + ViewedAt time.Time + }{ + Ctx: ctx, + UserID: userID, + OrganizationID: organizationID, + BranchID: branchID, + ViewedAt: viewedAt, + } + mock.lockUpsertDocumentBranchView.Lock() + mock.calls.UpsertDocumentBranchView = append(mock.calls.UpsertDocumentBranchView, callInfo) + mock.lockUpsertDocumentBranchView.Unlock() + if mock.UpsertDocumentBranchViewFunc == nil { + var ( + errOut error + ) + return errOut + } + return mock.UpsertDocumentBranchViewFunc(ctx, userID, organizationID, branchID, viewedAt) +} + +// UpsertDocumentBranchViewCalls gets all the calls that were made to UpsertDocumentBranchView. +// Check the length with: +// +// len(mockedTx.UpsertDocumentBranchViewCalls()) +func (mock *TxMock) UpsertDocumentBranchViewCalls() []struct { + Ctx context.Context + UserID string + OrganizationID string + BranchID xid.ID + ViewedAt time.Time +} { + var calls []struct { + Ctx context.Context + UserID string + OrganizationID string + BranchID xid.ID + ViewedAt time.Time + } + mock.lockUpsertDocumentBranchView.RLock() + calls = mock.calls.UpsertDocumentBranchView + mock.lockUpsertDocumentBranchView.RUnlock() + return calls +} + // UpsertDocumentMaintainers calls UpsertDocumentMaintainersFunc. func (mock *TxMock) UpsertDocumentMaintainers(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error { callInfo := struct { diff --git a/server/core/internal/server/internal/document/branch_http.go b/server/core/internal/server/internal/document/branch_http.go index b644e7b5..13f546a6 100644 --- a/server/core/internal/server/internal/document/branch_http.go +++ b/server/core/internal/server/internal/document/branch_http.go @@ -9,6 +9,7 @@ import ( "github.com/oxynote/oxynote/server/core/internal/search" "github.com/oxynote/oxynote/server/core/internal/server/internal/auth" "github.com/oxynote/oxynote/server/core/pkg/httpserver" + "github.com/oxynote/oxynote/server/core/pkg/timeutil" "github.com/rs/xid" ) @@ -619,3 +620,53 @@ func (h *Handler) DeleteDocumentBranch(w http.ResponseWriter, r *http.Request) { http.StatusNoContent, ) } + +// RecordDocumentBranchView handles recording that the user opened a +// document branch. +func (h *Handler) RecordDocumentBranchView(w http.ResponseWriter, r *http.Request) { + session, ok := auth.RequireSession(h.log, w, r) + if !ok { + return + } + + id, err := httpserver.ExtractNamedID(r, "documentId") + if err != nil { + httpserver.RespondError(h.log, w, err) + return + } + + branchID, err := httpserver.ExtractNamedID(r, "branchId") + if err != nil { + httpserver.RespondError(h.log, w, err) + return + } + + doc, err := h.db.FetchDocumentByBranchID(r.Context(), branchID, session.ActiveOrganizationID) + if err != nil { + httpserver.RespondError(h.log, w, err) + return + } + + if doc.ID != id { + httpserver.RespondError(h.log, w, ErrBranchMismatch) + return + } + + if err := h.db.UpsertDocumentBranchView( + r.Context(), + session.UserID, + session.ActiveOrganizationID, + branchID, + timeutil.Now(), + ); err != nil { + httpserver.RespondError(h.log, w, err) + return + } + + httpserver.Respond( + h.log, + w, + nil, + http.StatusNoContent, + ) +} diff --git a/server/core/internal/server/internal/document/branch_http_test.go b/server/core/internal/server/internal/document/branch_http_test.go index c2c07d3c..59db0694 100644 --- a/server/core/internal/server/internal/document/branch_http_test.go +++ b/server/core/internal/server/internal/document/branch_http_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "testing" + "time" "github.com/guregu/null/v5" documentCore "github.com/oxynote/oxynote/server/core/internal/document" @@ -1349,3 +1350,100 @@ func Test_Handler_UpdateDocumentBranch(t *testing.T) { }) } } + +func Test_Handler_RecordDocumentBranchView(t *testing.T) { + cc := map[string]struct { + DB *DBMock + NoSession bool + OmitDoc bool + OmitBranch bool + RespCode int + Upserted int + }{ + "No session in context": { + DB: &DBMock{}, + NoSession: true, + RespCode: http.StatusUnauthorized, + }, + "Missing document ID parameter": { + DB: &DBMock{}, + OmitDoc: true, + RespCode: http.StatusNotFound, + }, + "Missing branch ID parameter": { + DB: &DBMock{}, + OmitBranch: true, + RespCode: http.StatusNotFound, + }, + "Error returned by DB.FetchDocumentByBranchID": { + DB: &DBMock{ + FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) { + return nil, assert.AnError + }, + }, + RespCode: http.StatusInternalServerError, + }, + "Branch of another document": { + DB: &DBMock{ + FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) { + doc := branchDoc(_branchID) + doc.ID = xid.New() + + return doc, nil + }, + }, + RespCode: http.StatusNotFound, + }, + "Error returned by DB.UpsertDocumentBranchView": { + DB: &DBMock{ + FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) { + return branchDoc(_branchID), nil + }, + UpsertDocumentBranchViewFunc: func(context.Context, string, string, xid.ID, time.Time) error { + return assert.AnError + }, + }, + RespCode: http.StatusInternalServerError, + Upserted: 1, + }, + "Successful record": { + DB: &DBMock{ + FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) { + return branchDoc(_branchID), nil + }, + }, + RespCode: http.StatusNoContent, + Upserted: 1, + }, + } + + for cn, c := range cc { + t.Run(cn, func(t *testing.T) { + t.Parallel() + + hdl, _ := newTestHandler(c.DB, &fakePublisher{}) + + rec := httptest.NewRecorder() + + hdl.RecordDocumentBranchView(rec, newRequest(http.MethodPost, "", c.NoSession, c.OmitDoc, c.OmitBranch)) + + assert.Equal(t, c.RespCode, rec.Code) + + ff := c.DB.UpsertDocumentBranchViewCalls() + require.Len(t, ff, c.Upserted) + + if c.Upserted == 0 { + return + } + + assert.Equal(t, "u1", ff[0].UserID) + assert.Equal(t, "org1", ff[0].OrganizationID) + assert.Equal(t, _branchID, ff[0].BranchID) + assert.False(t, ff[0].ViewedAt.IsZero()) + + if c.RespCode == http.StatusNoContent { + assert.Empty(t, rec.Body.String()) + } + }) + } +} diff --git a/server/core/internal/server/internal/document/document_http.go b/server/core/internal/server/internal/document/document_http.go index 1dd7cad8..fd46a547 100644 --- a/server/core/internal/server/internal/document/document_http.go +++ b/server/core/internal/server/internal/document/document_http.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "path" + "time" "github.com/guregu/null/v5" documentCore "github.com/oxynote/oxynote/server/core/internal/document" @@ -1041,6 +1042,16 @@ type BranchesDBAgent interface { // ReplaceBranchTags should make the target branch carry exactly the // tags the source branch carries. ReplaceBranchTags(ctx context.Context, organizationID string, fromBranchID, toBranchID xid.ID) error + + // UpsertDocumentBranchView should record that the user viewed the + // branch at the given time. + UpsertDocumentBranchView( + ctx context.Context, + userID string, + organizationID string, + branchID xid.ID, + viewedAt time.Time, + ) error } // TreeDBAgent is an interface that handles communication with the document diff --git a/server/core/internal/server/router.go b/server/core/internal/server/router.go index ecc25464..73ad7f8f 100644 --- a/server/core/internal/server/router.go +++ b/server/core/internal/server/router.go @@ -311,6 +311,7 @@ func (s *Server) router() chi.Router { sssr.Get("/", s.handlers.document.FetchDocumentBranches) sssr.Route("/{branchId}", func(ssssr chi.Router) { ssssr.Put("/review-approve", s.handlers.document.UpdateBranchReviewApproval) + ssssr.Post("/views", s.handlers.document.RecordDocumentBranchView) ssssr.Post("/blocks/{blockUid}/run", s.handlers.block.RunBlock) ssssr.Route("/reviewers", func(sssssr chi.Router) { sssssr.Get("/", s.handlers.document.FetchBranchReviewers) diff --git a/web/app/composables/api/useDocumentAPI.nuxt.test.ts b/web/app/composables/api/useDocumentAPI.nuxt.test.ts index 8a26516e..2ae220f2 100644 --- a/web/app/composables/api/useDocumentAPI.nuxt.test.ts +++ b/web/app/composables/api/useDocumentAPI.nuxt.test.ts @@ -813,6 +813,36 @@ describe("useDocumentAPI", { concurrent: false }, () => { }) }) + describe("recordBranchView", () => { + it("posts the view of the branch", async ({ expect }) => { + const viewCalls = mockEndpoint( + "POST", + `/api/documents/${DOC_ID}/branches/${BRANCH_ID}/views`, + () => null, + ) + const api = makeDocumentAPI() + + await api.recordBranchView(DOC_ID, BRANCH_ID) + + expect(viewCalls).toHaveLength(1) + }) + + it("bails out for a non-xid branch id without a request", async ({ + expect, + }) => { + const viewCalls = mockEndpoint( + "POST", + `/api/documents/${DOC_ID}/branches/optimistic/views`, + () => null, + ) + const api = makeDocumentAPI() + + await api.recordBranchView(DOC_ID, "optimistic") + + expect(viewCalls).toHaveLength(0) + }) + }) + describe("updateDocumentBranch", () => { it("bails out for a non-xid document id without a request", async ({ expect, diff --git a/web/app/composables/api/useDocumentAPI.ts b/web/app/composables/api/useDocumentAPI.ts index 3f3ee69f..acc8ec55 100644 --- a/web/app/composables/api/useDocumentAPI.ts +++ b/web/app/composables/api/useDocumentAPI.ts @@ -547,6 +547,18 @@ export default function () { ) } + // records that the user opened the branch, which search lists as + // recently viewed + async function recordBranchView(docId: string, branchId: string) { + if (!isXid(docId) || !isXid(branchId)) { + return + } + + await $coreAPIClient(`/api/documents/${docId}/branches/${branchId}/views`, { + method: "POST", + }) + } + // runs a block, which core resolves from the stored branch and // dispatches on its kind. For a metric block that means probing whether // the data it simulates has arrived; core owns the answer because it @@ -1223,6 +1235,7 @@ export default function () { createDocument, duplicateDocument, searchDocuments, + recordBranchView, deleteDocument, updateDocumentTreeElementCache, useFetchDocumentMaintainersByDocId, diff --git a/web/app/pages/[[organizationSlug]]/[[documentSlug]].vue b/web/app/pages/[[organizationSlug]]/[[documentSlug]].vue index c8b38078..ec339e0c 100644 --- a/web/app/pages/[[organizationSlug]]/[[documentSlug]].vue +++ b/web/app/pages/[[organizationSlug]]/[[documentSlug]].vue @@ -116,6 +116,7 @@ const { deleteDocument, duplicateDocument, useFetchDocumentBranchesByDocId, + recordBranchView, } = useDocumentAPI() const { deleteTag, updateTagTreeDocumentCache } = useTagAPI() const { fetchOrganization } = useAuthSession() @@ -200,6 +201,8 @@ const breadcrumbs = computed(() => { ) }) +let recordedBranchView = "" + useHead({ title: () => activeDocMetadata.value @@ -237,6 +240,34 @@ watchImmediate( }, ) +watchImmediate( + [ + () => activeDocMetadata.value?.id, + () => editorStore.activeBranchId, + () => fetchBranches.data.value, + ], + ([docId, branchId, branches]) => { + if ( + !docId || + !branchId || + !branches?.some((b) => b.branchId === branchId) + ) { + return + } + + const key = `${docId}-${branchId}` + if (key === recordedBranchView) { + return + } + + recordedBranchView = key + + void recordBranchView(docId, branchId).catch((err: unknown) => { + console.error("Failed to record branch view:", err) + }) + }, +) + if (import.meta.dev) { watchOnce( () => loadedSections.value.sidebar,