mirror of
https://github.com/oxynote/oxynote.git
synced 2026-10-17 10:59:20 +02:00
add from type to document view tracking
This commit is contained in:
parent
93ee0db7d5
commit
d05db14270
|
|
@ -33,6 +33,10 @@ mailpit, since the example runs without email.
|
|||
`linux/arm64`. Local builds (`prod-build`, `e2e-prod`) target only the
|
||||
Docker engine's own architecture, so CI's release gate tests amd64
|
||||
alone.
|
||||
- **`server.json`** is the image's entry in the official MCP Registry.
|
||||
After a release, set its `version` to the tag and run
|
||||
`mcp-publisher publish docker/prod/server.json`. The URL path must match
|
||||
the Caddyfile's `/core` route.
|
||||
|
||||
## Invariants
|
||||
|
||||
|
|
|
|||
31
docker/prod/server.json
Normal file
31
docker/prod/server.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
||||
"name": "io.github.oxynote/oxynote",
|
||||
"title": "Oxynote",
|
||||
"description": "Self-hosted team docs with live Prometheus charts. Read, write and query your docs over MCP.",
|
||||
"version": "0.8.4",
|
||||
"repository": {
|
||||
"url": "https://github.com/oxynote/oxynote",
|
||||
"source": "github",
|
||||
"id": "1321878383"
|
||||
},
|
||||
"icons": [
|
||||
{
|
||||
"src": "https://raw.githubusercontent.com/oxynote/oxynote/main/web/public/favicon.svg",
|
||||
"mimeType": "image/svg+xml",
|
||||
"sizes": ["any"]
|
||||
}
|
||||
],
|
||||
"remotes": [
|
||||
{
|
||||
"type": "streamable-http",
|
||||
"url": "https://{host}/core/api/mcp",
|
||||
"variables": {
|
||||
"host": {
|
||||
"description": "Domain of your Oxynote instance, e.g. docs.example.com",
|
||||
"isRequired": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -6,27 +6,32 @@ import (
|
|||
|
||||
sq "github.com/Masterminds/squirrel"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/internal/search"
|
||||
"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.
|
||||
// given time, under search.ViewFromAll and, when the origin is another
|
||||
// one, under that origin too. A branch viewed from the same origin before
|
||||
// keeps one row with the newer time.
|
||||
func (a *agent) UpsertDocumentBranchView(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
organizationID string,
|
||||
branchID xid.ID,
|
||||
from search.ViewFrom,
|
||||
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").
|
||||
b := a.builder.Insert("document_branch_views").
|
||||
Columns("fk_user_id", "fk_organization_id", "fk_branch_id", "viewed_from", "viewed_at").
|
||||
Values(userID, organizationID, branchID, search.ViewFromAll, viewedAt)
|
||||
|
||||
if from != search.ViewFromAll {
|
||||
b = b.Values(userID, organizationID, branchID, from, viewedAt)
|
||||
}
|
||||
|
||||
q, args := b.
|
||||
Suffix("ON CONFLICT (fk_user_id, fk_branch_id, viewed_from) DO UPDATE SET viewed_at = EXCLUDED.viewed_at").
|
||||
MustSql()
|
||||
|
||||
_, err := a.sql.ExecContext(ctx, q, args...)
|
||||
|
|
@ -34,26 +39,29 @@ func (a *agent) UpsertDocumentBranchView(
|
|||
return err
|
||||
}
|
||||
|
||||
// FetchRecentlyViewedDocuments fetches up to limit branches the user viewed
|
||||
// in the organization, joined against their document, most recent first.
|
||||
// FetchRecentlyViewedDocuments fetches up to limit branches the user
|
||||
// viewed from the given origin in the organization, joined against their
|
||||
// document, most recent first.
|
||||
func (a *agent) FetchRecentlyViewedDocuments(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
organizationID string,
|
||||
from search.ViewFrom,
|
||||
limit int,
|
||||
) ([]document.Document, error) {
|
||||
q, args := a.selectDocumentBranch(a.builder.Select()).
|
||||
) ([]search.RecentDocument, error) {
|
||||
q, args := a.selectDocumentBranch(a.builder.Select("dbv.viewed_at")).
|
||||
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,
|
||||
"dbv.viewed_from": from,
|
||||
"documents.fk_organization_id": organizationID,
|
||||
}).
|
||||
OrderBy("dbv.viewed_at DESC", "db.id DESC").
|
||||
Limit(uint64(limit)).
|
||||
MustSql()
|
||||
|
||||
docs := []document.Document{}
|
||||
docs := []search.RecentDocument{}
|
||||
|
||||
if err := sqlx.SelectContext(ctx, a.sql, &docs, q, args...); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/internal/search"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/timeutil"
|
||||
"github.com/rs/xid"
|
||||
|
|
@ -18,8 +19,9 @@ func Test_agent_UpsertDocumentBranchView(t *testing.T) {
|
|||
UserID string
|
||||
OrganizationID string
|
||||
BranchID xid.ID
|
||||
From search.ViewFrom
|
||||
ViewedAt time.Time
|
||||
Result []document.Document
|
||||
Results map[search.ViewFrom][]search.RecentDocument
|
||||
Err error
|
||||
}
|
||||
|
||||
|
|
@ -33,6 +35,7 @@ func Test_agent_UpsertDocumentBranchView(t *testing.T) {
|
|||
UserID: prepUsers(t, db, 1)[0],
|
||||
OrganizationID: doc.OrganizationID,
|
||||
BranchID: xid.New(),
|
||||
From: search.ViewFromAll,
|
||||
ViewedAt: now,
|
||||
Err: assert.AnError,
|
||||
}
|
||||
|
|
@ -44,8 +47,27 @@ func Test_agent_UpsertDocumentBranchView(t *testing.T) {
|
|||
UserID: prepUsers(t, db, 1)[0],
|
||||
OrganizationID: doc.OrganizationID,
|
||||
BranchID: doc.BranchID,
|
||||
From: search.ViewFromAll,
|
||||
ViewedAt: now,
|
||||
Result: []document.Document{*doc},
|
||||
Results: map[search.ViewFrom][]search.RecentDocument{
|
||||
search.ViewFromAll: {{Document: *doc, ViewedAt: now}},
|
||||
search.ViewFromSearch: {},
|
||||
},
|
||||
}
|
||||
},
|
||||
"Successful insert from search": 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,
|
||||
From: search.ViewFromSearch,
|
||||
ViewedAt: now,
|
||||
Results: map[search.ViewFrom][]search.RecentDocument{
|
||||
search.ViewFromAll: {{Document: *doc, ViewedAt: now}},
|
||||
search.ViewFromSearch: {{Document: *doc, ViewedAt: now}},
|
||||
},
|
||||
}
|
||||
},
|
||||
"Successful update of an earlier view": func(t *testing.T, db *DB) tcase {
|
||||
|
|
@ -58,6 +80,7 @@ func Test_agent_UpsertDocumentBranchView(t *testing.T) {
|
|||
user,
|
||||
doc.OrganizationID,
|
||||
doc.BranchID,
|
||||
search.ViewFromSearch,
|
||||
now.Add(-time.Duration(len(docs)-i)*time.Hour),
|
||||
))
|
||||
}
|
||||
|
|
@ -66,8 +89,43 @@ func Test_agent_UpsertDocumentBranchView(t *testing.T) {
|
|||
UserID: user,
|
||||
OrganizationID: docs[0].OrganizationID,
|
||||
BranchID: docs[0].BranchID,
|
||||
From: search.ViewFromSearch,
|
||||
ViewedAt: now,
|
||||
Result: []document.Document{*docs[0], *docs[1]},
|
||||
Results: map[search.ViewFrom][]search.RecentDocument{
|
||||
search.ViewFromAll: {
|
||||
{Document: *docs[0], ViewedAt: now},
|
||||
{Document: *docs[1], ViewedAt: now.Add(-time.Hour)},
|
||||
},
|
||||
search.ViewFromSearch: {
|
||||
{Document: *docs[0], ViewedAt: now},
|
||||
{Document: *docs[1], ViewedAt: now.Add(-time.Hour)},
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
"Successful update that leaves the other origin as it was": func(t *testing.T, db *DB) tcase {
|
||||
doc := prepDocuments(t, db, 1, nil)[0]
|
||||
user := prepUsers(t, db, 1)[0]
|
||||
|
||||
require.NoError(t, db.UpsertDocumentBranchView(
|
||||
context.Background(),
|
||||
user,
|
||||
doc.OrganizationID,
|
||||
doc.BranchID,
|
||||
search.ViewFromSearch,
|
||||
now.Add(-time.Hour),
|
||||
))
|
||||
|
||||
return tcase{
|
||||
UserID: user,
|
||||
OrganizationID: doc.OrganizationID,
|
||||
BranchID: doc.BranchID,
|
||||
From: search.ViewFromAll,
|
||||
ViewedAt: now,
|
||||
Results: map[search.ViewFrom][]search.RecentDocument{
|
||||
search.ViewFromAll: {{Document: *doc, ViewedAt: now}},
|
||||
search.ViewFromSearch: {{Document: *doc, ViewedAt: now.Add(-time.Hour)}},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -79,16 +137,18 @@ func Test_agent_UpsertDocumentBranchView(t *testing.T) {
|
|||
db := prepTempDB(t)
|
||||
c := cfn(t, db)
|
||||
|
||||
err := db.UpsertDocumentBranchView(context.Background(), c.UserID, c.OrganizationID, c.BranchID, c.ViewedAt)
|
||||
err := db.UpsertDocumentBranchView(context.Background(), c.UserID, c.OrganizationID, c.BranchID, c.From, 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)
|
||||
for from, exp := range c.Results {
|
||||
res, err := db.FetchRecentlyViewedDocuments(context.Background(), c.UserID, c.OrganizationID, from, 10)
|
||||
require.NoError(t, err)
|
||||
testutil.AssertFilterEqual(t, exp, res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -97,15 +157,16 @@ func Test_agent_FetchRecentlyViewedDocuments(t *testing.T) {
|
|||
type tcase struct {
|
||||
UserID string
|
||||
OrganizationID string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
Result []document.Document
|
||||
Result []search.RecentDocument
|
||||
}
|
||||
|
||||
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) {
|
||||
// view records the user's views of the documents from the origin, the
|
||||
// first one viewed most recently.
|
||||
view := func(t *testing.T, db *DB, user string, from search.ViewFrom, docs ...*document.Document) {
|
||||
t.Helper()
|
||||
|
||||
for i, doc := range docs {
|
||||
|
|
@ -114,6 +175,7 @@ func Test_agent_FetchRecentlyViewedDocuments(t *testing.T) {
|
|||
user,
|
||||
doc.OrganizationID,
|
||||
doc.BranchID,
|
||||
from,
|
||||
now.Add(-time.Duration(i)*time.Minute),
|
||||
))
|
||||
}
|
||||
|
|
@ -126,21 +188,43 @@ func Test_agent_FetchRecentlyViewedDocuments(t *testing.T) {
|
|||
return tcase{
|
||||
UserID: prepUsers(t, db, 1)[0],
|
||||
OrganizationID: doc.OrganizationID,
|
||||
From: search.ViewFromAll,
|
||||
Limit: 10,
|
||||
Result: []document.Document{},
|
||||
Result: []search.RecentDocument{},
|
||||
}
|
||||
},
|
||||
"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])
|
||||
view(t, db, user, search.ViewFromAll, docs[2], docs[0], docs[1])
|
||||
|
||||
return tcase{
|
||||
UserID: user,
|
||||
OrganizationID: docs[0].OrganizationID,
|
||||
From: search.ViewFromAll,
|
||||
Limit: 2,
|
||||
Result: []document.Document{*docs[2], *docs[0]},
|
||||
Result: []search.RecentDocument{
|
||||
{Document: *docs[2], ViewedAt: now},
|
||||
{Document: *docs[0], ViewedAt: now.Add(-time.Minute)},
|
||||
},
|
||||
}
|
||||
},
|
||||
"Views from other origins are left out": func(t *testing.T, db *DB) tcase {
|
||||
docs := prepDocumentBranches(t, db, 2, nil)
|
||||
user := prepUsers(t, db, 1)[0]
|
||||
|
||||
view(t, db, user, search.ViewFromAll, docs[0])
|
||||
view(t, db, user, search.ViewFromSearch, docs[1])
|
||||
|
||||
return tcase{
|
||||
UserID: user,
|
||||
OrganizationID: docs[0].OrganizationID,
|
||||
From: search.ViewFromSearch,
|
||||
Limit: 10,
|
||||
Result: []search.RecentDocument{
|
||||
{Document: *docs[1], ViewedAt: now},
|
||||
},
|
||||
}
|
||||
},
|
||||
"Views of other users and organizations are left out": func(t *testing.T, db *DB) tcase {
|
||||
|
|
@ -148,29 +232,35 @@ func Test_agent_FetchRecentlyViewedDocuments(t *testing.T) {
|
|||
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])
|
||||
view(t, db, users[0], search.ViewFromAll, docs[0], other)
|
||||
view(t, db, users[1], search.ViewFromAll, docs[0])
|
||||
|
||||
return tcase{
|
||||
UserID: users[0],
|
||||
OrganizationID: docs[0].OrganizationID,
|
||||
From: search.ViewFromAll,
|
||||
Limit: 10,
|
||||
Result: []document.Document{*docs[0]},
|
||||
Result: []search.RecentDocument{
|
||||
{Document: *docs[0], ViewedAt: now},
|
||||
},
|
||||
}
|
||||
},
|
||||
"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])
|
||||
view(t, db, user, search.ViewFromAll, 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,
|
||||
From: search.ViewFromAll,
|
||||
Limit: 10,
|
||||
Result: []document.Document{*docs[1]},
|
||||
Result: []search.RecentDocument{
|
||||
{Document: *docs[1], ViewedAt: now.Add(-time.Minute)},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
|
@ -182,7 +272,7 @@ func Test_agent_FetchRecentlyViewedDocuments(t *testing.T) {
|
|||
db := prepTempDB(t)
|
||||
c := cfn(t, db)
|
||||
|
||||
res, err := db.FetchRecentlyViewedDocuments(context.Background(), c.UserID, c.OrganizationID, c.Limit)
|
||||
res, err := db.FetchRecentlyViewedDocuments(context.Background(), c.UserID, c.OrganizationID, c.From, c.Limit)
|
||||
require.NoError(t, err)
|
||||
testutil.AssertFilterEqual(t, c.Result, res)
|
||||
})
|
||||
|
|
@ -194,7 +284,7 @@ func Test_agent_FetchRecentlyViewedDocuments(t *testing.T) {
|
|||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
res, err := db.FetchRecentlyViewedDocuments(ctx, "user", "org", 10)
|
||||
res, err := db.FetchRecentlyViewedDocuments(ctx, "user", "org", search.ViewFromAll, 10)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, res)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
-- +migrate Up
|
||||
|
||||
ALTER TABLE document_branch_views ADD COLUMN viewed_from TEXT NOT NULL DEFAULT 'all';
|
||||
ALTER TABLE document_branch_views ALTER COLUMN viewed_from DROP DEFAULT;
|
||||
ALTER TABLE document_branch_views DROP CONSTRAINT document_branch_views_pkey;
|
||||
ALTER TABLE document_branch_views ADD PRIMARY KEY (fk_user_id, fk_branch_id, viewed_from);
|
||||
DROP INDEX document_branch_views_fk_user_id_viewed_at_idx;
|
||||
CREATE INDEX document_branch_views_fk_user_id_viewed_from_viewed_at_idx ON document_branch_views (fk_user_id, fk_organization_id, viewed_from, viewed_at DESC);
|
||||
|
||||
-- +migrate Down
|
||||
|
||||
DELETE FROM document_branch_views WHERE viewed_from <> 'all';
|
||||
DROP INDEX document_branch_views_fk_user_id_viewed_from_viewed_at_idx;
|
||||
ALTER TABLE document_branch_views DROP CONSTRAINT document_branch_views_pkey;
|
||||
ALTER TABLE document_branch_views ADD PRIMARY KEY (fk_user_id, fk_branch_id);
|
||||
ALTER TABLE document_branch_views DROP COLUMN viewed_from;
|
||||
CREATE INDEX document_branch_views_fk_user_id_viewed_at_idx ON document_branch_views (fk_user_id, fk_organization_id, viewed_at DESC);
|
||||
|
|
@ -67,7 +67,7 @@ type BranchPage struct {
|
|||
// of them, highlighted. The hits come in the order SearchGroups shows a
|
||||
// group's hits in, so a group's NextHitsToken continues the group.
|
||||
func (i *Index) SearchBranch(ctx context.Context, bq BranchQuery) (BranchPage, error) {
|
||||
if err := ValidateQuery(bq.Query); err != nil {
|
||||
if err := validatePagedQuery(bq.Query); err != nil {
|
||||
return BranchPage{}, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,6 +126,10 @@ func Test_Index_SearchBranch(t *testing.T) {
|
|||
Query: BranchQuery{OrganizationID: "org-1", BranchID: main.BranchID},
|
||||
Err: ErrInvalidQuery,
|
||||
},
|
||||
"Short query": {
|
||||
Query: BranchQuery{OrganizationID: "org-1", BranchID: main.BranchID, Query: "s"},
|
||||
Err: ErrInvalidQuery,
|
||||
},
|
||||
"Invalid page token": {
|
||||
Query: BranchQuery{OrganizationID: "org-1", BranchID: main.BranchID, Query: "shipment", PageToken: "%%%"},
|
||||
Err: ErrInvalidPageToken,
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ type groupEntries struct {
|
|||
// request. A page token is an offset into that order. Only the page's
|
||||
// own entries are then read in full and highlighted.
|
||||
func (i *Index) SearchGroups(ctx context.Context, gq GroupQuery) (GroupPage, error) {
|
||||
if err := ValidateQuery(gq.Query); err != nil {
|
||||
if err := validatePagedQuery(gq.Query); err != nil {
|
||||
return GroupPage{}, err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -191,6 +191,10 @@ func Test_Index_SearchGroups(t *testing.T) {
|
|||
Query: GroupQuery{OrganizationID: "org-1"},
|
||||
Err: ErrInvalidQuery,
|
||||
},
|
||||
"Short query": {
|
||||
Query: GroupQuery{OrganizationID: "org-1", Query: "s"},
|
||||
Err: ErrInvalidQuery,
|
||||
},
|
||||
"Invalid page token": {
|
||||
Query: GroupQuery{OrganizationID: "org-1", Query: "shipment", PageToken: "%%%"},
|
||||
Err: ErrInvalidPageToken,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ const (
|
|||
// bounds the work one request can ask for.
|
||||
MaxQueryLength = 200
|
||||
|
||||
// _minPagedQueryLength is the fewest characters a query of the paged
|
||||
// searches holds, surrounding spaces excluded. Shorter terms match
|
||||
// too much of the dictionary to rank.
|
||||
_minPagedQueryLength = 2
|
||||
|
||||
// _searchResultLimit caps the number of hits the search endpoint
|
||||
// returns.
|
||||
_searchResultLimit = 30
|
||||
|
|
@ -55,7 +60,7 @@ const (
|
|||
)
|
||||
|
||||
// ErrInvalidQuery is returned when a search query is empty or over
|
||||
// MaxQueryLength.
|
||||
// MaxQueryLength, or when a paged search query is too short.
|
||||
var ErrInvalidQuery = errutil.New(http.StatusBadRequest, "document.invalid_search_query", "invalid search query")
|
||||
|
||||
// ValidateQuery reports whether the query is one the index will run.
|
||||
|
|
@ -67,6 +72,17 @@ func ValidateQuery(q string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// validatePagedQuery reports whether the query is one the paged searches
|
||||
// run. On top of ValidateQuery, they refuse one under
|
||||
// _minPagedQueryLength.
|
||||
func validatePagedQuery(q string) error {
|
||||
if utf8.RuneCountInString(strings.TrimSpace(q)) < _minPagedQueryLength {
|
||||
return ErrInvalidQuery
|
||||
}
|
||||
|
||||
return ValidateQuery(q)
|
||||
}
|
||||
|
||||
// SearchDocuments searches the organization's entries and returns them as
|
||||
// the search endpoint's JSON: the matched terms wrapped in mark tags and
|
||||
// the text cropped to a fragment around the best match.
|
||||
|
|
|
|||
|
|
@ -169,6 +169,27 @@ func Test_ValidateQuery(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_validatePagedQuery(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Query string
|
||||
Err error
|
||||
}{
|
||||
"Empty query": {Err: ErrInvalidQuery},
|
||||
"Short query": {Query: " a ", Err: ErrInvalidQuery},
|
||||
"Overlong query": {Query: strings.Repeat("a", MaxQueryLength+1), Err: ErrInvalidQuery},
|
||||
"Query at the minimum": {Query: "éa"},
|
||||
"Plain query": {Query: "deploy"},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testutil.AssertEqualError(t, c.Err, validatePagedQuery(c.Query))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Index_search(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
94
server/core/internal/search/recent.go
Normal file
94
server/core/internal/search/recent.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/errutil"
|
||||
)
|
||||
|
||||
// All available ViewFrom constants.
|
||||
const (
|
||||
// ViewFromAll records every opening of a branch, whatever led to it.
|
||||
ViewFromAll ViewFrom = "all"
|
||||
|
||||
// ViewFromSearch records an opening of a branch from search results.
|
||||
ViewFromSearch ViewFrom = "search"
|
||||
)
|
||||
|
||||
// ErrInvalidViewFrom is returned when a view names an unknown origin.
|
||||
var ErrInvalidViewFrom = errutil.New(http.StatusBadRequest, "document.invalid_view_from", "invalid view origin")
|
||||
|
||||
// ViewFrom names what led the user to open a branch.
|
||||
type ViewFrom string
|
||||
|
||||
// Validate reports whether the origin is a known one.
|
||||
func (vf ViewFrom) Validate() error {
|
||||
switch vf {
|
||||
case ViewFromAll, ViewFromSearch:
|
||||
return nil
|
||||
default:
|
||||
return ErrInvalidViewFrom
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalText returns the origin as text.
|
||||
func (vf ViewFrom) MarshalText() ([]byte, error) {
|
||||
return []byte(vf), nil
|
||||
}
|
||||
|
||||
// UnmarshalText parses an origin, refusing an unknown one so a bad value
|
||||
// is reported where it is decoded.
|
||||
func (vf *ViewFrom) UnmarshalText(text []byte) error {
|
||||
v := ViewFrom(strings.ToLower(strings.TrimSpace(string(text))))
|
||||
if err := v.Validate(); err != nil {
|
||||
return fmt.Errorf("view origin must be one of %s, %s, got %q", ViewFromAll, ViewFromSearch, text)
|
||||
}
|
||||
|
||||
*vf = v
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecentDocument is a document branch the user viewed, with the time of
|
||||
// the latest view.
|
||||
type RecentDocument struct {
|
||||
document.Document
|
||||
|
||||
// ViewedAt specifies the time of the latest view.
|
||||
ViewedAt time.Time `json:"viewedAt" db:"viewed_at"`
|
||||
}
|
||||
|
||||
// RecentResponse is the recent documents endpoint's answer.
|
||||
type RecentResponse struct {
|
||||
// Results specifies the document branches, most recently viewed
|
||||
// first.
|
||||
Results []RecentResult `json:"results"`
|
||||
}
|
||||
|
||||
// NewRecentResponse describes the recently viewed document branches.
|
||||
func NewRecentResponse(docs []RecentDocument) RecentResponse {
|
||||
res := RecentResponse{
|
||||
Results: make([]RecentResult, 0, len(docs)),
|
||||
}
|
||||
|
||||
for _, doc := range docs {
|
||||
res.Results = append(res.Results, RecentResult{
|
||||
ResultDocument: newResultDocument(doc.Document),
|
||||
ViewedAt: doc.ViewedAt,
|
||||
})
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// RecentResult is one document branch the user viewed.
|
||||
type RecentResult struct {
|
||||
ResultDocument
|
||||
|
||||
// ViewedAt specifies the time of the latest view.
|
||||
ViewedAt time.Time `json:"viewedAt"`
|
||||
}
|
||||
110
server/core/internal/search/recent_test.go
Normal file
110
server/core/internal/search/recent_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/guregu/null/v5"
|
||||
"github.com/oxynote/oxynote/server/core/internal/document"
|
||||
"github.com/oxynote/oxynote/server/core/pkg/testutil"
|
||||
"github.com/rs/xid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_ViewFrom_Validate(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
ViewFrom ViewFrom
|
||||
Err error
|
||||
}{
|
||||
"Empty": {Err: ErrInvalidViewFrom},
|
||||
"Unknown": {ViewFrom: "tree", Err: ErrInvalidViewFrom},
|
||||
"All": {ViewFrom: ViewFromAll},
|
||||
"Search": {ViewFrom: ViewFromSearch},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
testutil.AssertEqualError(t, c.Err, c.ViewFrom.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ViewFrom_MarshalText(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
text, err := ViewFromSearch.MarshalText()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "search", string(text))
|
||||
}
|
||||
|
||||
func Test_ViewFrom_UnmarshalText(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Text string
|
||||
ViewFrom ViewFrom
|
||||
Err error
|
||||
}{
|
||||
"Unknown": {Text: "tree", Err: assert.AnError},
|
||||
"Known": {Text: "search", ViewFrom: ViewFromSearch},
|
||||
"Padded upper case": {Text: " ALL ", ViewFrom: ViewFromAll},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var vf ViewFrom
|
||||
|
||||
err := vf.UnmarshalText([]byte(c.Text))
|
||||
testutil.AssertEqualError(t, c.Err, err)
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(t, c.ViewFrom, vf)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_NewRecentResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
doc := document.Document{
|
||||
ID: xid.New(),
|
||||
BranchID: xid.New(),
|
||||
BranchName: "main",
|
||||
DocumentName: "Shipments",
|
||||
Icon: "truck",
|
||||
Default: true,
|
||||
UpdatedAt: time.Date(2026, 10, 4, 9, 12, 41, 0, time.UTC),
|
||||
LastUpdatedBy: null.StringFrom("usr-1"),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(NewRecentResponse(nil))
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"results": []}`, string(data))
|
||||
|
||||
data, err = json.Marshal(NewRecentResponse([]RecentDocument{{
|
||||
Document: doc,
|
||||
ViewedAt: time.Date(2026, 10, 7, 8, 0, 0, 0, time.UTC),
|
||||
}}))
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{
|
||||
"results": [
|
||||
{
|
||||
"id": "`+doc.ID.String()+`",
|
||||
"title": "Shipments",
|
||||
"titleHtml": null,
|
||||
"icon": "truck",
|
||||
"branch": {"id": "`+doc.BranchID.String()+`", "name": "main", "default": true},
|
||||
"updatedAt": "2026-10-04T09:12:41Z",
|
||||
"updatedBy": "usr-1",
|
||||
"viewedAt": "2026-10-07T08:00:00Z"
|
||||
}
|
||||
]
|
||||
}`, string(data))
|
||||
}
|
||||
|
|
@ -66,26 +66,6 @@ func NewResponse(page GroupPage, docs []document.Document) Response {
|
|||
return res
|
||||
}
|
||||
|
||||
// NewRecentResponse lists the documents with no hits, as the search
|
||||
// endpoint answers an empty query.
|
||||
func NewRecentResponse(docs []document.Document) Response {
|
||||
res := Response{
|
||||
Total: ResponseTotal{
|
||||
Documents: len(docs),
|
||||
},
|
||||
Results: make([]Result, 0, len(docs)),
|
||||
}
|
||||
|
||||
for _, doc := range docs {
|
||||
res.Results = append(res.Results, Result{
|
||||
Document: newResultDocument(doc),
|
||||
Hits: []ResultHit{},
|
||||
})
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// ResponseTotal holds the counts across every page of a search.
|
||||
type ResponseTotal struct {
|
||||
// Hits specifies the number of matching blocks, document names
|
||||
|
|
|
|||
|
|
@ -132,50 +132,6 @@ func Test_NewResponse(t *testing.T) {
|
|||
}`, string(data))
|
||||
}
|
||||
|
||||
func Test_NewRecentResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
updatedAt := time.Date(2026, 10, 4, 9, 12, 41, 0, time.UTC)
|
||||
|
||||
doc := document.Document{
|
||||
ID: xid.New(),
|
||||
BranchID: xid.New(),
|
||||
BranchName: "main",
|
||||
DocumentName: "Shipments",
|
||||
Icon: "truck",
|
||||
Default: true,
|
||||
UpdatedAt: updatedAt,
|
||||
LastUpdatedBy: null.StringFrom("usr-1"),
|
||||
}
|
||||
|
||||
data, err := json.Marshal(NewRecentResponse(nil))
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{"total": {"hits": 0, "documents": 0, "capped": false}, "nextToken": null, "results": []}`, string(data))
|
||||
|
||||
data, err = json.Marshal(NewRecentResponse([]document.Document{doc}))
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{
|
||||
"total": {"hits": 0, "documents": 1, "capped": false},
|
||||
"nextToken": null,
|
||||
"results": [
|
||||
{
|
||||
"document": {
|
||||
"id": "`+doc.ID.String()+`",
|
||||
"title": "Shipments",
|
||||
"titleHtml": null,
|
||||
"icon": "truck",
|
||||
"branch": {"id": "`+doc.BranchID.String()+`", "name": "main", "default": true},
|
||||
"updatedAt": "2026-10-04T09:12:41Z",
|
||||
"updatedBy": "usr-1"
|
||||
},
|
||||
"hits": [],
|
||||
"totalHits": 0,
|
||||
"nextHitsToken": null
|
||||
}
|
||||
]
|
||||
}`, string(data))
|
||||
}
|
||||
|
||||
func Test_NewBranchResponse(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ var _ DB = &DBMock{}
|
|||
// FetchOrganizationMembersFunc: func(ctx context.Context, organizationID string) ([]string, error) {
|
||||
// panic("mock out the FetchOrganizationMembers method")
|
||||
// },
|
||||
// FetchRecentlyViewedDocumentsFunc: func(ctx context.Context, userID string, organizationID string, limit int) ([]document.Document, error) {
|
||||
// FetchRecentlyViewedDocumentsFunc: func(ctx context.Context, userID string, organizationID string, from search.ViewFrom, limit int) ([]search.RecentDocument, error) {
|
||||
// panic("mock out the FetchRecentlyViewedDocuments method")
|
||||
// },
|
||||
// FetchSlackAppByOrganizationIDFunc: func(ctx context.Context, organizationID string) (*slackCore.App, error) {
|
||||
|
|
@ -305,7 +305,7 @@ 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 {
|
||||
// UpsertDocumentBranchViewFunc: func(ctx context.Context, userID string, organizationID string, branchID xid.ID, from search.ViewFrom, viewedAt time.Time) error {
|
||||
// panic("mock out the UpsertDocumentBranchView method")
|
||||
// },
|
||||
// UpsertDocumentMaintainersFunc: func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error {
|
||||
|
|
@ -460,7 +460,7 @@ type DBMock struct {
|
|||
FetchOrganizationMembersFunc func(ctx context.Context, organizationID string) ([]string, error)
|
||||
|
||||
// FetchRecentlyViewedDocumentsFunc mocks the FetchRecentlyViewedDocuments method.
|
||||
FetchRecentlyViewedDocumentsFunc func(ctx context.Context, userID string, organizationID string, limit int) ([]document.Document, error)
|
||||
FetchRecentlyViewedDocumentsFunc func(ctx context.Context, userID string, organizationID string, from search.ViewFrom, limit int) ([]search.RecentDocument, error)
|
||||
|
||||
// FetchSlackAppByOrganizationIDFunc mocks the FetchSlackAppByOrganizationID method.
|
||||
FetchSlackAppByOrganizationIDFunc func(ctx context.Context, organizationID string) (*slackCore.App, error)
|
||||
|
|
@ -592,7 +592,7 @@ type DBMock struct {
|
|||
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
|
||||
UpsertDocumentBranchViewFunc func(ctx context.Context, userID string, organizationID string, branchID xid.ID, from search.ViewFrom, viewedAt time.Time) error
|
||||
|
||||
// UpsertDocumentMaintainersFunc mocks the UpsertDocumentMaintainers method.
|
||||
UpsertDocumentMaintainersFunc func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error
|
||||
|
|
@ -1032,6 +1032,8 @@ type DBMock struct {
|
|||
UserID string
|
||||
// OrganizationID is the organizationID argument value.
|
||||
OrganizationID string
|
||||
// From is the from argument value.
|
||||
From search.ViewFrom
|
||||
// Limit is the limit argument value.
|
||||
Limit int
|
||||
}
|
||||
|
|
@ -1398,6 +1400,8 @@ type DBMock struct {
|
|||
OrganizationID string
|
||||
// BranchID is the branchID argument value.
|
||||
BranchID xid.ID
|
||||
// From is the from argument value.
|
||||
From search.ViewFrom
|
||||
// ViewedAt is the viewedAt argument value.
|
||||
ViewedAt time.Time
|
||||
}
|
||||
|
|
@ -3564,16 +3568,18 @@ func (mock *DBMock) FetchOrganizationMembersCalls() []struct {
|
|||
}
|
||||
|
||||
// FetchRecentlyViewedDocuments calls FetchRecentlyViewedDocumentsFunc.
|
||||
func (mock *DBMock) FetchRecentlyViewedDocuments(ctx context.Context, userID string, organizationID string, limit int) ([]document.Document, error) {
|
||||
func (mock *DBMock) FetchRecentlyViewedDocuments(ctx context.Context, userID string, organizationID string, from search.ViewFrom, limit int) ([]search.RecentDocument, error) {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
}{
|
||||
Ctx: ctx,
|
||||
UserID: userID,
|
||||
OrganizationID: organizationID,
|
||||
From: from,
|
||||
Limit: limit,
|
||||
}
|
||||
mock.lockFetchRecentlyViewedDocuments.Lock()
|
||||
|
|
@ -3581,12 +3587,12 @@ func (mock *DBMock) FetchRecentlyViewedDocuments(ctx context.Context, userID str
|
|||
mock.lockFetchRecentlyViewedDocuments.Unlock()
|
||||
if mock.FetchRecentlyViewedDocumentsFunc == nil {
|
||||
var (
|
||||
documentsOut []document.Document
|
||||
errOut error
|
||||
recentDocumentsOut []search.RecentDocument
|
||||
errOut error
|
||||
)
|
||||
return documentsOut, errOut
|
||||
return recentDocumentsOut, errOut
|
||||
}
|
||||
return mock.FetchRecentlyViewedDocumentsFunc(ctx, userID, organizationID, limit)
|
||||
return mock.FetchRecentlyViewedDocumentsFunc(ctx, userID, organizationID, from, limit)
|
||||
}
|
||||
|
||||
// FetchRecentlyViewedDocumentsCalls gets all the calls that were made to FetchRecentlyViewedDocuments.
|
||||
|
|
@ -3597,12 +3603,14 @@ func (mock *DBMock) FetchRecentlyViewedDocumentsCalls() []struct {
|
|||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
}
|
||||
mock.lockFetchRecentlyViewedDocuments.RLock()
|
||||
|
|
@ -5399,18 +5407,20 @@ func (mock *DBMock) UpdateUserImageCalls() []struct {
|
|||
}
|
||||
|
||||
// UpsertDocumentBranchView calls UpsertDocumentBranchViewFunc.
|
||||
func (mock *DBMock) UpsertDocumentBranchView(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error {
|
||||
func (mock *DBMock) UpsertDocumentBranchView(ctx context.Context, userID string, organizationID string, branchID xid.ID, from search.ViewFrom, viewedAt time.Time) error {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
BranchID xid.ID
|
||||
From search.ViewFrom
|
||||
ViewedAt time.Time
|
||||
}{
|
||||
Ctx: ctx,
|
||||
UserID: userID,
|
||||
OrganizationID: organizationID,
|
||||
BranchID: branchID,
|
||||
From: from,
|
||||
ViewedAt: viewedAt,
|
||||
}
|
||||
mock.lockUpsertDocumentBranchView.Lock()
|
||||
|
|
@ -5422,7 +5432,7 @@ func (mock *DBMock) UpsertDocumentBranchView(ctx context.Context, userID string,
|
|||
)
|
||||
return errOut
|
||||
}
|
||||
return mock.UpsertDocumentBranchViewFunc(ctx, userID, organizationID, branchID, viewedAt)
|
||||
return mock.UpsertDocumentBranchViewFunc(ctx, userID, organizationID, branchID, from, viewedAt)
|
||||
}
|
||||
|
||||
// UpsertDocumentBranchViewCalls gets all the calls that were made to UpsertDocumentBranchView.
|
||||
|
|
@ -5434,6 +5444,7 @@ func (mock *DBMock) UpsertDocumentBranchViewCalls() []struct {
|
|||
UserID string
|
||||
OrganizationID string
|
||||
BranchID xid.ID
|
||||
From search.ViewFrom
|
||||
ViewedAt time.Time
|
||||
} {
|
||||
var calls []struct {
|
||||
|
|
@ -5441,6 +5452,7 @@ func (mock *DBMock) UpsertDocumentBranchViewCalls() []struct {
|
|||
UserID string
|
||||
OrganizationID string
|
||||
BranchID xid.ID
|
||||
From search.ViewFrom
|
||||
ViewedAt time.Time
|
||||
}
|
||||
mock.lockUpsertDocumentBranchView.RLock()
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ var _ DB = &DBMock{}
|
|||
// FetchDocumentsByBranchIDsFunc: func(ctx context.Context, branchIDs []xid.ID, organizationID string) ([]documentCore.Document, error) {
|
||||
// panic("mock out the FetchDocumentsByBranchIDs method")
|
||||
// },
|
||||
// FetchRecentlyViewedDocumentsFunc: func(ctx context.Context, userID string, organizationID string, limit int) ([]documentCore.Document, error) {
|
||||
// FetchRecentlyViewedDocumentsFunc: func(ctx context.Context, userID string, organizationID string, from search.ViewFrom, limit int) ([]search.RecentDocument, error) {
|
||||
// panic("mock out the FetchRecentlyViewedDocuments method")
|
||||
// },
|
||||
// InsertBranchReviewerFunc: func(ctx context.Context, reviewer documentCore.BranchReviewer) error {
|
||||
|
|
@ -143,7 +143,7 @@ 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 {
|
||||
// UpsertDocumentBranchViewFunc: func(ctx context.Context, userID string, organizationID string, branchID xid.ID, from search.ViewFrom, viewedAt time.Time) error {
|
||||
// panic("mock out the UpsertDocumentBranchView method")
|
||||
// },
|
||||
// UpsertDocumentMaintainersFunc: func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error {
|
||||
|
|
@ -229,7 +229,7 @@ type DBMock struct {
|
|||
FetchDocumentsByBranchIDsFunc func(ctx context.Context, branchIDs []xid.ID, organizationID string) ([]documentCore.Document, error)
|
||||
|
||||
// FetchRecentlyViewedDocumentsFunc mocks the FetchRecentlyViewedDocuments method.
|
||||
FetchRecentlyViewedDocumentsFunc func(ctx context.Context, userID string, organizationID string, limit int) ([]documentCore.Document, error)
|
||||
FetchRecentlyViewedDocumentsFunc func(ctx context.Context, userID string, organizationID string, from search.ViewFrom, limit int) ([]search.RecentDocument, error)
|
||||
|
||||
// InsertBranchReviewerFunc mocks the InsertBranchReviewer method.
|
||||
InsertBranchReviewerFunc func(ctx context.Context, reviewer documentCore.BranchReviewer) error
|
||||
|
|
@ -274,7 +274,7 @@ type DBMock struct {
|
|||
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
|
||||
UpsertDocumentBranchViewFunc func(ctx context.Context, userID string, organizationID string, branchID xid.ID, from search.ViewFrom, viewedAt time.Time) error
|
||||
|
||||
// UpsertDocumentMaintainersFunc mocks the UpsertDocumentMaintainers method.
|
||||
UpsertDocumentMaintainersFunc func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error
|
||||
|
|
@ -507,6 +507,8 @@ type DBMock struct {
|
|||
UserID string
|
||||
// OrganizationID is the organizationID argument value.
|
||||
OrganizationID string
|
||||
// From is the from argument value.
|
||||
From search.ViewFrom
|
||||
// Limit is the limit argument value.
|
||||
Limit int
|
||||
}
|
||||
|
|
@ -638,6 +640,8 @@ type DBMock struct {
|
|||
OrganizationID string
|
||||
// BranchID is the branchID argument value.
|
||||
BranchID xid.ID
|
||||
// From is the from argument value.
|
||||
From search.ViewFrom
|
||||
// ViewedAt is the viewedAt argument value.
|
||||
ViewedAt time.Time
|
||||
}
|
||||
|
|
@ -1750,16 +1754,18 @@ func (mock *DBMock) FetchDocumentsByBranchIDsCalls() []struct {
|
|||
}
|
||||
|
||||
// FetchRecentlyViewedDocuments calls FetchRecentlyViewedDocumentsFunc.
|
||||
func (mock *DBMock) FetchRecentlyViewedDocuments(ctx context.Context, userID string, organizationID string, limit int) ([]documentCore.Document, error) {
|
||||
func (mock *DBMock) FetchRecentlyViewedDocuments(ctx context.Context, userID string, organizationID string, from search.ViewFrom, limit int) ([]search.RecentDocument, error) {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
}{
|
||||
Ctx: ctx,
|
||||
UserID: userID,
|
||||
OrganizationID: organizationID,
|
||||
From: from,
|
||||
Limit: limit,
|
||||
}
|
||||
mock.lockFetchRecentlyViewedDocuments.Lock()
|
||||
|
|
@ -1767,12 +1773,12 @@ func (mock *DBMock) FetchRecentlyViewedDocuments(ctx context.Context, userID str
|
|||
mock.lockFetchRecentlyViewedDocuments.Unlock()
|
||||
if mock.FetchRecentlyViewedDocumentsFunc == nil {
|
||||
var (
|
||||
documentsOut []documentCore.Document
|
||||
errOut error
|
||||
recentDocumentsOut []search.RecentDocument
|
||||
errOut error
|
||||
)
|
||||
return documentsOut, errOut
|
||||
return recentDocumentsOut, errOut
|
||||
}
|
||||
return mock.FetchRecentlyViewedDocumentsFunc(ctx, userID, organizationID, limit)
|
||||
return mock.FetchRecentlyViewedDocumentsFunc(ctx, userID, organizationID, from, limit)
|
||||
}
|
||||
|
||||
// FetchRecentlyViewedDocumentsCalls gets all the calls that were made to FetchRecentlyViewedDocuments.
|
||||
|
|
@ -1783,12 +1789,14 @@ func (mock *DBMock) FetchRecentlyViewedDocumentsCalls() []struct {
|
|||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
}
|
||||
mock.lockFetchRecentlyViewedDocuments.RLock()
|
||||
|
|
@ -2384,18 +2392,20 @@ func (mock *DBMock) UpdateDocumentTreeCalls() []struct {
|
|||
}
|
||||
|
||||
// UpsertDocumentBranchView calls UpsertDocumentBranchViewFunc.
|
||||
func (mock *DBMock) UpsertDocumentBranchView(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error {
|
||||
func (mock *DBMock) UpsertDocumentBranchView(ctx context.Context, userID string, organizationID string, branchID xid.ID, from search.ViewFrom, viewedAt time.Time) error {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
BranchID xid.ID
|
||||
From search.ViewFrom
|
||||
ViewedAt time.Time
|
||||
}{
|
||||
Ctx: ctx,
|
||||
UserID: userID,
|
||||
OrganizationID: organizationID,
|
||||
BranchID: branchID,
|
||||
From: from,
|
||||
ViewedAt: viewedAt,
|
||||
}
|
||||
mock.lockUpsertDocumentBranchView.Lock()
|
||||
|
|
@ -2407,7 +2417,7 @@ func (mock *DBMock) UpsertDocumentBranchView(ctx context.Context, userID string,
|
|||
)
|
||||
return errOut
|
||||
}
|
||||
return mock.UpsertDocumentBranchViewFunc(ctx, userID, organizationID, branchID, viewedAt)
|
||||
return mock.UpsertDocumentBranchViewFunc(ctx, userID, organizationID, branchID, from, viewedAt)
|
||||
}
|
||||
|
||||
// UpsertDocumentBranchViewCalls gets all the calls that were made to UpsertDocumentBranchView.
|
||||
|
|
@ -2419,6 +2429,7 @@ func (mock *DBMock) UpsertDocumentBranchViewCalls() []struct {
|
|||
UserID string
|
||||
OrganizationID string
|
||||
BranchID xid.ID
|
||||
From search.ViewFrom
|
||||
ViewedAt time.Time
|
||||
} {
|
||||
var calls []struct {
|
||||
|
|
@ -2426,6 +2437,7 @@ func (mock *DBMock) UpsertDocumentBranchViewCalls() []struct {
|
|||
UserID string
|
||||
OrganizationID string
|
||||
BranchID xid.ID
|
||||
From search.ViewFrom
|
||||
ViewedAt time.Time
|
||||
}
|
||||
mock.lockUpsertDocumentBranchView.RLock()
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ var _ Tx = &TxMock{}
|
|||
// FetchDocumentsByBranchIDsFunc: func(ctx context.Context, branchIDs []xid.ID, organizationID string) ([]documentCore.Document, error) {
|
||||
// panic("mock out the FetchDocumentsByBranchIDs method")
|
||||
// },
|
||||
// FetchRecentlyViewedDocumentsFunc: func(ctx context.Context, userID string, organizationID string, limit int) ([]documentCore.Document, error) {
|
||||
// FetchRecentlyViewedDocumentsFunc: func(ctx context.Context, userID string, organizationID string, from search.ViewFrom, limit int) ([]search.RecentDocument, error) {
|
||||
// panic("mock out the FetchRecentlyViewedDocuments method")
|
||||
// },
|
||||
// InsertBranchReviewerFunc: func(ctx context.Context, reviewer documentCore.BranchReviewer) error {
|
||||
|
|
@ -146,7 +146,7 @@ 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 {
|
||||
// UpsertDocumentBranchViewFunc: func(ctx context.Context, userID string, organizationID string, branchID xid.ID, from search.ViewFrom, viewedAt time.Time) error {
|
||||
// panic("mock out the UpsertDocumentBranchView method")
|
||||
// },
|
||||
// UpsertDocumentMaintainersFunc: func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error {
|
||||
|
|
@ -232,7 +232,7 @@ type TxMock struct {
|
|||
FetchDocumentsByBranchIDsFunc func(ctx context.Context, branchIDs []xid.ID, organizationID string) ([]documentCore.Document, error)
|
||||
|
||||
// FetchRecentlyViewedDocumentsFunc mocks the FetchRecentlyViewedDocuments method.
|
||||
FetchRecentlyViewedDocumentsFunc func(ctx context.Context, userID string, organizationID string, limit int) ([]documentCore.Document, error)
|
||||
FetchRecentlyViewedDocumentsFunc func(ctx context.Context, userID string, organizationID string, from search.ViewFrom, limit int) ([]search.RecentDocument, error)
|
||||
|
||||
// InsertBranchReviewerFunc mocks the InsertBranchReviewer method.
|
||||
InsertBranchReviewerFunc func(ctx context.Context, reviewer documentCore.BranchReviewer) error
|
||||
|
|
@ -280,7 +280,7 @@ type TxMock struct {
|
|||
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
|
||||
UpsertDocumentBranchViewFunc func(ctx context.Context, userID string, organizationID string, branchID xid.ID, from search.ViewFrom, viewedAt time.Time) error
|
||||
|
||||
// UpsertDocumentMaintainersFunc mocks the UpsertDocumentMaintainers method.
|
||||
UpsertDocumentMaintainersFunc func(ctx context.Context, documentID xid.ID, organizationID string, maintainerIDs []string) error
|
||||
|
|
@ -509,6 +509,8 @@ type TxMock struct {
|
|||
UserID string
|
||||
// OrganizationID is the organizationID argument value.
|
||||
OrganizationID string
|
||||
// From is the from argument value.
|
||||
From search.ViewFrom
|
||||
// Limit is the limit argument value.
|
||||
Limit int
|
||||
}
|
||||
|
|
@ -643,6 +645,8 @@ type TxMock struct {
|
|||
OrganizationID string
|
||||
// BranchID is the branchID argument value.
|
||||
BranchID xid.ID
|
||||
// From is the from argument value.
|
||||
From search.ViewFrom
|
||||
// ViewedAt is the viewedAt argument value.
|
||||
ViewedAt time.Time
|
||||
}
|
||||
|
|
@ -1747,16 +1751,18 @@ func (mock *TxMock) FetchDocumentsByBranchIDsCalls() []struct {
|
|||
}
|
||||
|
||||
// FetchRecentlyViewedDocuments calls FetchRecentlyViewedDocumentsFunc.
|
||||
func (mock *TxMock) FetchRecentlyViewedDocuments(ctx context.Context, userID string, organizationID string, limit int) ([]documentCore.Document, error) {
|
||||
func (mock *TxMock) FetchRecentlyViewedDocuments(ctx context.Context, userID string, organizationID string, from search.ViewFrom, limit int) ([]search.RecentDocument, error) {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
}{
|
||||
Ctx: ctx,
|
||||
UserID: userID,
|
||||
OrganizationID: organizationID,
|
||||
From: from,
|
||||
Limit: limit,
|
||||
}
|
||||
mock.lockFetchRecentlyViewedDocuments.Lock()
|
||||
|
|
@ -1764,12 +1770,12 @@ func (mock *TxMock) FetchRecentlyViewedDocuments(ctx context.Context, userID str
|
|||
mock.lockFetchRecentlyViewedDocuments.Unlock()
|
||||
if mock.FetchRecentlyViewedDocumentsFunc == nil {
|
||||
var (
|
||||
documentsOut []documentCore.Document
|
||||
errOut error
|
||||
recentDocumentsOut []search.RecentDocument
|
||||
errOut error
|
||||
)
|
||||
return documentsOut, errOut
|
||||
return recentDocumentsOut, errOut
|
||||
}
|
||||
return mock.FetchRecentlyViewedDocumentsFunc(ctx, userID, organizationID, limit)
|
||||
return mock.FetchRecentlyViewedDocumentsFunc(ctx, userID, organizationID, from, limit)
|
||||
}
|
||||
|
||||
// FetchRecentlyViewedDocumentsCalls gets all the calls that were made to FetchRecentlyViewedDocuments.
|
||||
|
|
@ -1780,12 +1786,14 @@ func (mock *TxMock) FetchRecentlyViewedDocumentsCalls() []struct {
|
|||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
} {
|
||||
var calls []struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
}
|
||||
mock.lockFetchRecentlyViewedDocuments.RLock()
|
||||
|
|
@ -2411,18 +2419,20 @@ func (mock *TxMock) UpdateDocumentTreeCalls() []struct {
|
|||
}
|
||||
|
||||
// UpsertDocumentBranchView calls UpsertDocumentBranchViewFunc.
|
||||
func (mock *TxMock) UpsertDocumentBranchView(ctx context.Context, userID string, organizationID string, branchID xid.ID, viewedAt time.Time) error {
|
||||
func (mock *TxMock) UpsertDocumentBranchView(ctx context.Context, userID string, organizationID string, branchID xid.ID, from search.ViewFrom, viewedAt time.Time) error {
|
||||
callInfo := struct {
|
||||
Ctx context.Context
|
||||
UserID string
|
||||
OrganizationID string
|
||||
BranchID xid.ID
|
||||
From search.ViewFrom
|
||||
ViewedAt time.Time
|
||||
}{
|
||||
Ctx: ctx,
|
||||
UserID: userID,
|
||||
OrganizationID: organizationID,
|
||||
BranchID: branchID,
|
||||
From: from,
|
||||
ViewedAt: viewedAt,
|
||||
}
|
||||
mock.lockUpsertDocumentBranchView.Lock()
|
||||
|
|
@ -2434,7 +2444,7 @@ func (mock *TxMock) UpsertDocumentBranchView(ctx context.Context, userID string,
|
|||
)
|
||||
return errOut
|
||||
}
|
||||
return mock.UpsertDocumentBranchViewFunc(ctx, userID, organizationID, branchID, viewedAt)
|
||||
return mock.UpsertDocumentBranchViewFunc(ctx, userID, organizationID, branchID, from, viewedAt)
|
||||
}
|
||||
|
||||
// UpsertDocumentBranchViewCalls gets all the calls that were made to UpsertDocumentBranchView.
|
||||
|
|
@ -2446,6 +2456,7 @@ func (mock *TxMock) UpsertDocumentBranchViewCalls() []struct {
|
|||
UserID string
|
||||
OrganizationID string
|
||||
BranchID xid.ID
|
||||
From search.ViewFrom
|
||||
ViewedAt time.Time
|
||||
} {
|
||||
var calls []struct {
|
||||
|
|
@ -2453,6 +2464,7 @@ func (mock *TxMock) UpsertDocumentBranchViewCalls() []struct {
|
|||
UserID string
|
||||
OrganizationID string
|
||||
BranchID xid.ID
|
||||
From search.ViewFrom
|
||||
ViewedAt time.Time
|
||||
}
|
||||
mock.lockUpsertDocumentBranchView.RLock()
|
||||
|
|
|
|||
|
|
@ -622,13 +622,24 @@ func (h *Handler) DeleteDocumentBranch(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// RecordDocumentBranchView handles recording that the user opened a
|
||||
// document branch.
|
||||
// document branch, from anywhere unless the request names an origin.
|
||||
func (h *Handler) RecordDocumentBranchView(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := auth.RequireSession(h.log, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
inp := struct {
|
||||
From search.ViewFrom `schema:"from"`
|
||||
}{
|
||||
From: search.ViewFromAll,
|
||||
}
|
||||
|
||||
if err := httpserver.DecodeForm(r, &inp); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := httpserver.ExtractNamedID(r, "documentId")
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
|
|
@ -657,6 +668,7 @@ func (h *Handler) RecordDocumentBranchView(w http.ResponseWriter, r *http.Reques
|
|||
session.UserID,
|
||||
session.ActiveOrganizationID,
|
||||
branchID,
|
||||
inp.From,
|
||||
timeutil.Now(),
|
||||
); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
|
|
|
|||
|
|
@ -1352,19 +1352,31 @@ func Test_Handler_UpdateDocumentBranch(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_Handler_RecordDocumentBranchView(t *testing.T) {
|
||||
// stubBranch answers the branch lookup with a branch of the document.
|
||||
stubBranch := func(context.Context, xid.ID, string) (*documentCore.Document, error) {
|
||||
return branchDoc(_branchID), nil
|
||||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
NoSession bool
|
||||
OmitDoc bool
|
||||
OmitBranch bool
|
||||
Query string
|
||||
RespCode int
|
||||
Upserted int
|
||||
From search.ViewFrom
|
||||
}{
|
||||
"No session in context": {
|
||||
DB: &DBMock{},
|
||||
NoSession: true,
|
||||
RespCode: http.StatusUnauthorized,
|
||||
},
|
||||
"Invalid origin": {
|
||||
DB: &DBMock{},
|
||||
Query: "from=tree",
|
||||
RespCode: http.StatusBadRequest,
|
||||
},
|
||||
"Missing document ID parameter": {
|
||||
DB: &DBMock{},
|
||||
OmitDoc: true,
|
||||
|
|
@ -1396,24 +1408,31 @@ func Test_Handler_RecordDocumentBranchView(t *testing.T) {
|
|||
},
|
||||
"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 {
|
||||
FetchDocumentByBranchIDFunc: stubBranch,
|
||||
UpsertDocumentBranchViewFunc: func(context.Context, string, string, xid.ID, search.ViewFrom, time.Time) error {
|
||||
return assert.AnError
|
||||
},
|
||||
},
|
||||
RespCode: http.StatusInternalServerError,
|
||||
Upserted: 1,
|
||||
From: search.ViewFromAll,
|
||||
},
|
||||
"Successful record": {
|
||||
DB: &DBMock{
|
||||
FetchDocumentByBranchIDFunc: func(context.Context, xid.ID, string) (*documentCore.Document, error) {
|
||||
return branchDoc(_branchID), nil
|
||||
},
|
||||
FetchDocumentByBranchIDFunc: stubBranch,
|
||||
},
|
||||
RespCode: http.StatusNoContent,
|
||||
Upserted: 1,
|
||||
From: search.ViewFromAll,
|
||||
},
|
||||
"Successful record from search": {
|
||||
DB: &DBMock{
|
||||
FetchDocumentByBranchIDFunc: stubBranch,
|
||||
},
|
||||
Query: "from=search",
|
||||
RespCode: http.StatusNoContent,
|
||||
Upserted: 1,
|
||||
From: search.ViewFromSearch,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -1423,9 +1442,12 @@ func Test_Handler_RecordDocumentBranchView(t *testing.T) {
|
|||
|
||||
hdl, _ := newTestHandler(c.DB, &fakePublisher{})
|
||||
|
||||
req := newRequest(http.MethodPost, "", c.NoSession, c.OmitDoc, c.OmitBranch)
|
||||
req.URL.RawQuery = c.Query
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
hdl.RecordDocumentBranchView(rec, newRequest(http.MethodPost, "", c.NoSession, c.OmitDoc, c.OmitBranch))
|
||||
hdl.RecordDocumentBranchView(rec, req)
|
||||
|
||||
assert.Equal(t, c.RespCode, rec.Code)
|
||||
|
||||
|
|
@ -1439,6 +1461,7 @@ func Test_Handler_RecordDocumentBranchView(t *testing.T) {
|
|||
assert.Equal(t, "u1", ff[0].UserID)
|
||||
assert.Equal(t, "org1", ff[0].OrganizationID)
|
||||
assert.Equal(t, _branchID, ff[0].BranchID)
|
||||
assert.Equal(t, c.From, ff[0].From)
|
||||
assert.False(t, ff[0].ViewedAt.IsZero())
|
||||
|
||||
if c.RespCode == http.StatusNoContent {
|
||||
|
|
|
|||
|
|
@ -499,8 +499,7 @@ func (h *Handler) FetchDocumentTree(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
// SearchDocuments handles the search for document branches matching a
|
||||
// query, one page at a time. An empty query lists the branches the user
|
||||
// viewed most recently instead.
|
||||
// query, one page at a time.
|
||||
func (h *Handler) SearchDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := auth.RequireSession(h.log, w, r)
|
||||
if !ok {
|
||||
|
|
@ -520,24 +519,10 @@ func (h *Handler) SearchDocuments(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
limit := search.GroupLimit(inp.Limit)
|
||||
|
||||
if inp.Query == "" {
|
||||
docs, err := h.db.FetchRecentlyViewedDocuments(r.Context(), session.UserID, session.ActiveOrganizationID, limit)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, search.NewRecentResponse(docs), http.StatusOK)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
gq := search.GroupQuery{
|
||||
OrganizationID: session.ActiveOrganizationID,
|
||||
Query: inp.Query,
|
||||
Limit: limit,
|
||||
Limit: search.GroupLimit(inp.Limit),
|
||||
HitsLimit: inp.HitsLimit,
|
||||
PageToken: inp.NextToken,
|
||||
}
|
||||
|
|
@ -561,6 +546,42 @@ func (h *Handler) SearchDocuments(w http.ResponseWriter, r *http.Request) {
|
|||
httpserver.Respond(h.log, w, search.NewResponse(page, docs), http.StatusOK)
|
||||
}
|
||||
|
||||
// FetchRecentlyViewedDocuments handles the listing of the document
|
||||
// branches the user opened most recently, from anywhere unless the
|
||||
// request names an origin.
|
||||
func (h *Handler) FetchRecentlyViewedDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := auth.RequireSession(h.log, w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
inp := struct {
|
||||
From search.ViewFrom `schema:"from"`
|
||||
Limit int `schema:"limit"`
|
||||
}{
|
||||
From: search.ViewFromAll,
|
||||
}
|
||||
|
||||
if err := httpserver.DecodeForm(r, &inp); err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
docs, err := h.db.FetchRecentlyViewedDocuments(
|
||||
r.Context(),
|
||||
session.UserID,
|
||||
session.ActiveOrganizationID,
|
||||
inp.From,
|
||||
search.GroupLimit(inp.Limit),
|
||||
)
|
||||
if err != nil {
|
||||
httpserver.RespondError(h.log, w, err)
|
||||
return
|
||||
}
|
||||
|
||||
httpserver.Respond(h.log, w, search.NewRecentResponse(docs), http.StatusOK)
|
||||
}
|
||||
|
||||
// SearchDocumentsLegacy handles the search for blocks matching a query,
|
||||
// answered as a flat list.
|
||||
func (h *Handler) SearchDocumentsLegacy(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1117,18 +1138,26 @@ type BranchesDBAgent interface {
|
|||
// document branch views database.
|
||||
type BranchViewsDBAgent interface {
|
||||
// UpsertDocumentBranchView should record that the user viewed the
|
||||
// branch at the given time.
|
||||
// branch from the given origin at the given time.
|
||||
UpsertDocumentBranchView(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
organizationID string,
|
||||
branchID xid.ID,
|
||||
from search.ViewFrom,
|
||||
viewedAt time.Time,
|
||||
) error
|
||||
|
||||
// FetchRecentlyViewedDocuments should fetch up to limit branches the
|
||||
// user viewed in the organization, most recent first.
|
||||
FetchRecentlyViewedDocuments(ctx context.Context, userID, organizationID string, limit int) ([]documentCore.Document, error)
|
||||
// user viewed from the given origin in the organization, most recent
|
||||
// first.
|
||||
FetchRecentlyViewedDocuments(
|
||||
ctx context.Context,
|
||||
userID string,
|
||||
organizationID string,
|
||||
from search.ViewFrom,
|
||||
limit int,
|
||||
) ([]search.RecentDocument, error)
|
||||
}
|
||||
|
||||
// TreeDBAgent is an interface that handles communication with the document
|
||||
|
|
|
|||
|
|
@ -1287,39 +1287,11 @@ func Test_Handler_SearchDocuments(t *testing.T) {
|
|||
Query: "?q=shipment¤tDocId=",
|
||||
RespCode: http.StatusBadRequest,
|
||||
},
|
||||
"Error returned by DB.FetchRecentlyViewedDocuments": {
|
||||
DB: &DBMock{
|
||||
FetchRecentlyViewedDocumentsFunc: func(context.Context, string, string, int) ([]documentCore.Document, error) {
|
||||
return nil, assert.AnError
|
||||
},
|
||||
},
|
||||
Searcher: &SearcherMock{},
|
||||
RespCode: http.StatusInternalServerError,
|
||||
},
|
||||
"Successful listing of recent documents": {
|
||||
DB: &DBMock{
|
||||
FetchRecentlyViewedDocumentsFunc: func(context.Context, string, string, int) ([]documentCore.Document, error) {
|
||||
return []documentCore.Document{doc}, nil
|
||||
},
|
||||
},
|
||||
Searcher: &SearcherMock{},
|
||||
Query: "?limit=5",
|
||||
RespCode: http.StatusOK,
|
||||
RespJSON: `{
|
||||
"total": {"hits": 0, "documents": 1, "capped": false},
|
||||
"nextToken": null,
|
||||
"results": [{"document": ` + docJSON + `, "hits": [], "totalHits": 0, "nextHitsToken": null}]
|
||||
}`,
|
||||
Check: func(t *testing.T, db *DBMock, searcher *SearcherMock) {
|
||||
t.Helper()
|
||||
|
||||
ff := db.FetchRecentlyViewedDocumentsCalls()
|
||||
require.Len(t, ff, 1)
|
||||
assert.Equal(t, "u1", ff[0].UserID)
|
||||
assert.Equal(t, "org1", ff[0].OrganizationID)
|
||||
assert.Equal(t, 5, ff[0].Limit)
|
||||
assert.Empty(t, searcher.SearchGroupsCalls())
|
||||
},
|
||||
"Short query": {
|
||||
DB: &DBMock{},
|
||||
Searcher: stubSearcher(search.GroupPage{}, search.ErrInvalidQuery),
|
||||
Query: "?q=a",
|
||||
RespCode: http.StatusBadRequest,
|
||||
},
|
||||
"Error returned by Searcher.SearchGroups": {
|
||||
DB: &DBMock{},
|
||||
|
|
@ -1446,6 +1418,135 @@ func Test_Handler_SearchDocuments(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_Handler_FetchRecentlyViewedDocuments(t *testing.T) {
|
||||
doc := search.RecentDocument{
|
||||
ID: _documentID,
|
||||
OrganizationID: "org1",
|
||||
BranchID: _branchID,
|
||||
BranchName: documentCore.DefaultBranch,
|
||||
DocumentName: "Shipments",
|
||||
Icon: "truck",
|
||||
Default: true,
|
||||
UpdatedAt: time.Date(2026, 10, 4, 9, 12, 41, 0, time.UTC),
|
||||
LastUpdatedBy: null.StringFrom("u2"),
|
||||
ViewedAt: time.Date(2026, 10, 7, 8, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
// stubDocs answers the listing with the documents.
|
||||
stubDocs := func(docs ...search.RecentDocument) func(context.Context, string, string, search.ViewFrom, int) ([]search.RecentDocument, error) {
|
||||
return func(context.Context, string, string, search.ViewFrom, int) ([]search.RecentDocument, error) {
|
||||
return docs, nil
|
||||
}
|
||||
}
|
||||
|
||||
cc := map[string]struct {
|
||||
DB *DBMock
|
||||
NoSession bool
|
||||
Query string
|
||||
RespCode int
|
||||
RespJSON string
|
||||
From search.ViewFrom
|
||||
Limit int
|
||||
}{
|
||||
"No session in context": {
|
||||
DB: &DBMock{},
|
||||
NoSession: true,
|
||||
RespCode: http.StatusUnauthorized,
|
||||
},
|
||||
"Invalid limit": {
|
||||
DB: &DBMock{},
|
||||
Query: "?limit=abc",
|
||||
RespCode: http.StatusBadRequest,
|
||||
},
|
||||
"Invalid origin": {
|
||||
DB: &DBMock{},
|
||||
Query: "?from=tree",
|
||||
RespCode: http.StatusBadRequest,
|
||||
},
|
||||
"Error returned by DB.FetchRecentlyViewedDocuments": {
|
||||
DB: &DBMock{
|
||||
FetchRecentlyViewedDocumentsFunc: func(context.Context, string, string, search.ViewFrom, int) ([]search.RecentDocument, error) {
|
||||
return nil, assert.AnError
|
||||
},
|
||||
},
|
||||
RespCode: http.StatusInternalServerError,
|
||||
From: search.ViewFromAll,
|
||||
Limit: search.GroupLimitDefault,
|
||||
},
|
||||
"Successful listing": {
|
||||
DB: &DBMock{
|
||||
FetchRecentlyViewedDocumentsFunc: stubDocs(doc),
|
||||
},
|
||||
Query: "?from=search&limit=5",
|
||||
RespCode: http.StatusOK,
|
||||
RespJSON: `{
|
||||
"results": [{
|
||||
"id": "` + _documentID.String() + `",
|
||||
"title": "Shipments",
|
||||
"titleHtml": null,
|
||||
"icon": "truck",
|
||||
"branch": {"id": "` + _branchID.String() + `", "name": "main", "default": true},
|
||||
"updatedAt": "2026-10-04T09:12:41Z",
|
||||
"updatedBy": "u2",
|
||||
"viewedAt": "2026-10-07T08:00:00Z"
|
||||
}]
|
||||
}`,
|
||||
From: search.ViewFromSearch,
|
||||
Limit: 5,
|
||||
},
|
||||
"Successful listing capped at the maximum": {
|
||||
DB: &DBMock{
|
||||
FetchRecentlyViewedDocumentsFunc: stubDocs(),
|
||||
},
|
||||
Query: "?limit=500",
|
||||
RespCode: http.StatusOK,
|
||||
RespJSON: `{"results": []}`,
|
||||
From: search.ViewFromAll,
|
||||
Limit: search.GroupLimitMax,
|
||||
},
|
||||
}
|
||||
|
||||
for cn, c := range cc {
|
||||
t.Run(cn, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
hdl, _ := newTestHandler(c.DB, &fakePublisher{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "http://test.com/"+c.Query, http.NoBody)
|
||||
|
||||
if !c.NoSession {
|
||||
req = req.WithContext(auth.AddSessionToContext(req.Context(), auth.Session{
|
||||
UserID: "u1",
|
||||
ActiveOrganizationID: "org1",
|
||||
}))
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
hdl.FetchRecentlyViewedDocuments(rec, req)
|
||||
|
||||
assert.Equal(t, c.RespCode, rec.Code)
|
||||
|
||||
if c.RespJSON != "" {
|
||||
assert.JSONEq(t, c.RespJSON, rec.Body.String())
|
||||
}
|
||||
|
||||
ff := c.DB.FetchRecentlyViewedDocumentsCalls()
|
||||
|
||||
if c.Limit == 0 {
|
||||
assert.Empty(t, ff)
|
||||
return
|
||||
}
|
||||
|
||||
require.Len(t, ff, 1)
|
||||
assert.Equal(t, "u1", ff[0].UserID)
|
||||
assert.Equal(t, "org1", ff[0].OrganizationID)
|
||||
assert.Equal(t, c.From, ff[0].From)
|
||||
assert.Equal(t, c.Limit, ff[0].Limit)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Handler_SearchDocumentsLegacy(t *testing.T) {
|
||||
cc := map[string]struct {
|
||||
Searcher *SearcherMock
|
||||
|
|
|
|||
|
|
@ -301,6 +301,7 @@ func (s *Server) router() chi.Router {
|
|||
r.Route("/documents", func(sr chi.Router) {
|
||||
sr.Get("/search", s.handlers.document.SearchDocuments)
|
||||
sr.Get("/search/legacy", s.handlers.document.SearchDocumentsLegacy)
|
||||
sr.Get("/recent", s.handlers.document.FetchRecentlyViewedDocuments)
|
||||
sr.Post("/", s.handlers.document.CreateDocument)
|
||||
sr.Route("/{documentId}", func(ssr chi.Router) {
|
||||
ssr.Use(s.handlers.document.RequireDocumentAccess)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user