mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
track document views
This commit is contained in:
parent
9f4b3f5c40
commit
51c194317a
63
server/core/internal/db/document_branch_view.go
Normal file
63
server/core/internal/db/document_branch_view.go
Normal 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
|
||||
}
|
||||
200
server/core/internal/db/document_branch_view_test.go
Normal file
200
server/core/internal/db/document_branch_view_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user