add from type to document view tracking

This commit is contained in:
david 2026-10-07 17:05:37 +03:00
parent 93ee0db7d5
commit d05db14270
23 changed files with 735 additions and 198 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"`
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1287,39 +1287,11 @@ func Test_Handler_SearchDocuments(t *testing.T) {
Query: "?q=shipment&currentDocId=",
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

View File

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