track document views

This commit is contained in:
david 2026-10-06 17:33:43 +03:00
parent 9f4b3f5c40
commit 51c194317a
13 changed files with 730 additions and 0 deletions

View File

@ -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
}

View File

@ -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)
}

View File

@ -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;

View File

@ -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 {

View File

@ -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 {

View File

@ -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 {

View File

@ -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,
)
}

View File

@ -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())
}
})
}
}

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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,

View File

@ -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,