From d05db1427002107f3d0beef7ba3dfaaf7c71f6ff Mon Sep 17 00:00:00 2001 From: david <58231312+davseby@users.noreply.github.com> Date: Wed, 7 Oct 2026 17:05:37 +0300 Subject: [PATCH] add from type to document view tracking --- docker/prod/AGENTS.md | 4 + docker/prod/server.json | 31 ++++ .../core/internal/db/document_branch_view.go | 38 ++-- .../internal/db/document_branch_view_test.go | 132 +++++++++++--- .../007_document_branch_view_from.sql | 17 ++ server/core/internal/search/branch.go | 2 +- server/core/internal/search/branch_test.go | 4 + server/core/internal/search/group.go | 2 +- server/core/internal/search/group_test.go | 4 + server/core/internal/search/query.go | 18 +- server/core/internal/search/query_test.go | 21 +++ server/core/internal/search/recent.go | 94 ++++++++++ server/core/internal/search/recent_test.go | 110 ++++++++++++ server/core/internal/search/result.go | 20 --- server/core/internal/search/result_test.go | 44 ----- server/core/internal/server/0mock_db_test.go | 34 ++-- .../server/internal/document/0mock_db_test.go | 34 ++-- .../server/internal/document/0mock_tx_test.go | 34 ++-- .../server/internal/document/branch_http.go | 14 +- .../internal/document/branch_http_test.go | 39 +++- .../server/internal/document/document_http.go | 69 +++++--- .../internal/document/document_http_test.go | 167 ++++++++++++++---- server/core/internal/server/router.go | 1 + 23 files changed, 735 insertions(+), 198 deletions(-) create mode 100644 docker/prod/server.json create mode 100644 server/core/internal/db/migrations/007_document_branch_view_from.sql create mode 100644 server/core/internal/search/recent.go create mode 100644 server/core/internal/search/recent_test.go diff --git a/docker/prod/AGENTS.md b/docker/prod/AGENTS.md index df68bcc7..a9fb8c95 100644 --- a/docker/prod/AGENTS.md +++ b/docker/prod/AGENTS.md @@ -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 diff --git a/docker/prod/server.json b/docker/prod/server.json new file mode 100644 index 00000000..ef429134 --- /dev/null +++ b/docker/prod/server.json @@ -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 + } + } + } + ] +} diff --git a/server/core/internal/db/document_branch_view.go b/server/core/internal/db/document_branch_view.go index 3e645136..46cba98e 100644 --- a/server/core/internal/db/document_branch_view.go +++ b/server/core/internal/db/document_branch_view.go @@ -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 diff --git a/server/core/internal/db/document_branch_view_test.go b/server/core/internal/db/document_branch_view_test.go index b273c5ad..7db11ef3 100644 --- a/server/core/internal/db/document_branch_view_test.go +++ b/server/core/internal/db/document_branch_view_test.go @@ -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) } diff --git a/server/core/internal/db/migrations/007_document_branch_view_from.sql b/server/core/internal/db/migrations/007_document_branch_view_from.sql new file mode 100644 index 00000000..34246f07 --- /dev/null +++ b/server/core/internal/db/migrations/007_document_branch_view_from.sql @@ -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); diff --git a/server/core/internal/search/branch.go b/server/core/internal/search/branch.go index 36b9bac9..9248e839 100644 --- a/server/core/internal/search/branch.go +++ b/server/core/internal/search/branch.go @@ -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 } diff --git a/server/core/internal/search/branch_test.go b/server/core/internal/search/branch_test.go index 0ff60c29..09ddcab2 100644 --- a/server/core/internal/search/branch_test.go +++ b/server/core/internal/search/branch_test.go @@ -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, diff --git a/server/core/internal/search/group.go b/server/core/internal/search/group.go index ea7c57d0..2a79d386 100644 --- a/server/core/internal/search/group.go +++ b/server/core/internal/search/group.go @@ -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 } diff --git a/server/core/internal/search/group_test.go b/server/core/internal/search/group_test.go index 99092b92..2bae2f42 100644 --- a/server/core/internal/search/group_test.go +++ b/server/core/internal/search/group_test.go @@ -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, diff --git a/server/core/internal/search/query.go b/server/core/internal/search/query.go index 236d9d43..49c9693a 100644 --- a/server/core/internal/search/query.go +++ b/server/core/internal/search/query.go @@ -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. diff --git a/server/core/internal/search/query_test.go b/server/core/internal/search/query_test.go index d42108f5..bf9b72cd 100644 --- a/server/core/internal/search/query_test.go +++ b/server/core/internal/search/query_test.go @@ -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() diff --git a/server/core/internal/search/recent.go b/server/core/internal/search/recent.go new file mode 100644 index 00000000..63f28cd6 --- /dev/null +++ b/server/core/internal/search/recent.go @@ -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"` +} diff --git a/server/core/internal/search/recent_test.go b/server/core/internal/search/recent_test.go new file mode 100644 index 00000000..dc710604 --- /dev/null +++ b/server/core/internal/search/recent_test.go @@ -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)) +} diff --git a/server/core/internal/search/result.go b/server/core/internal/search/result.go index 3b77caa7..1b6e07d9 100644 --- a/server/core/internal/search/result.go +++ b/server/core/internal/search/result.go @@ -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 diff --git a/server/core/internal/search/result_test.go b/server/core/internal/search/result_test.go index 66fbec22..f3cb5565 100644 --- a/server/core/internal/search/result_test.go +++ b/server/core/internal/search/result_test.go @@ -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() diff --git a/server/core/internal/server/0mock_db_test.go b/server/core/internal/server/0mock_db_test.go index 1f274274..6f8e632c 100644 --- a/server/core/internal/server/0mock_db_test.go +++ b/server/core/internal/server/0mock_db_test.go @@ -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() diff --git a/server/core/internal/server/internal/document/0mock_db_test.go b/server/core/internal/server/internal/document/0mock_db_test.go index 30884e23..b13f471c 100644 --- a/server/core/internal/server/internal/document/0mock_db_test.go +++ b/server/core/internal/server/internal/document/0mock_db_test.go @@ -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() diff --git a/server/core/internal/server/internal/document/0mock_tx_test.go b/server/core/internal/server/internal/document/0mock_tx_test.go index 6ac18b55..04171e82 100644 --- a/server/core/internal/server/internal/document/0mock_tx_test.go +++ b/server/core/internal/server/internal/document/0mock_tx_test.go @@ -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() diff --git a/server/core/internal/server/internal/document/branch_http.go b/server/core/internal/server/internal/document/branch_http.go index ca7bb162..bdd8c832 100644 --- a/server/core/internal/server/internal/document/branch_http.go +++ b/server/core/internal/server/internal/document/branch_http.go @@ -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) diff --git a/server/core/internal/server/internal/document/branch_http_test.go b/server/core/internal/server/internal/document/branch_http_test.go index 9e953ac1..c18c3fa9 100644 --- a/server/core/internal/server/internal/document/branch_http_test.go +++ b/server/core/internal/server/internal/document/branch_http_test.go @@ -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 { diff --git a/server/core/internal/server/internal/document/document_http.go b/server/core/internal/server/internal/document/document_http.go index 132eb1aa..f905e673 100644 --- a/server/core/internal/server/internal/document/document_http.go +++ b/server/core/internal/server/internal/document/document_http.go @@ -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 diff --git a/server/core/internal/server/internal/document/document_http_test.go b/server/core/internal/server/internal/document/document_http_test.go index 78e0dfad..26bd060c 100644 --- a/server/core/internal/server/internal/document/document_http_test.go +++ b/server/core/internal/server/internal/document/document_http_test.go @@ -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 diff --git a/server/core/internal/server/router.go b/server/core/internal/server/router.go index 48654331..ad0efcbe 100644 --- a/server/core/internal/server/router.go +++ b/server/core/internal/server/router.go @@ -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)