Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
d504241
Scaffold docs plugin from starter template
catalintomai Jun 23, 2026
a4eb3fa
Remove starter-template boilerplate
catalintomai Jun 24, 2026
0c086f7
add page soft-delete and restore with child promotion
catalintomai Jun 25, 2026
ac2b171
test: add schema-isolated Postgres test harness
catalintomai Jun 29, 2026
12b1cc9
address coderabbitai comments
catalintomai Jun 30, 2026
4d12a5d
test: align DB test harness with storetest.MakeSqlSettings
catalintomai Jun 30, 2026
9e440da
MM-69268 - Page tree CRUD + URL API: spaces, pages, move, duplicate
catalintomai Jul 6, 2026
ee2c5e5
clean-ups
catalintomai Jul 7, 2026
fe64d0b
address coderabbitai comments + update comments
catalintomai Jul 8, 2026
12adb02
Sanitize error responses and handle max depth in duplicate
catalintomai Jul 8, 2026
0a9d7dd
address coderabbitai comment
catalintomai Jul 8, 2026
89a40ae
update Auditable interface
catalintomai Jul 8, 2026
bc7f979
use mmmodel.Auditable, drop local duplicate
catalintomai Jul 9, 2026
1360eac
Add space membership checks, WS events, and per-user space filtering
catalintomai Jul 9, 2026
ec5ead7
Page move/duplicate store ops, WS events, pagination, review fixes
catalintomai Jul 13, 2026
4458ead
MM-69271: page drafts, TipTap content handling, and presence
catalintomai Jul 15, 2026
904d496
remove empty app/export_test.go placeholder
catalintomai Jul 15, 2026
1481383
fix lint
catalintomai Jul 15, 2026
eab6ef8
fix lint(1)
catalintomai Jul 15, 2026
504e8bd
address coderabbitai comments
catalintomai Jul 15, 2026
39b913c
address coderabbitai comments
catalintomai Jul 15, 2026
2941ed3
post-merge fixes
catalintomai Jul 17, 2026
762eb46
address coderabbitai comments
catalintomai Jul 17, 2026
a6ef25b
Harden page-draft presence, content, and move handling; add tests
catalintomai Jul 20, 2026
59e8220
update comments
catalintomai Jul 20, 2026
4a6e466
consolidate depth consts
catalintomai Jul 20, 2026
f12424e
Add Props support and ID validation to draft publish/upsert
catalintomai Jul 21, 2026
f7bbfda
update comments - small improvs
catalintomai Jul 22, 2026
94a4f54
address review, update comments, renaming
catalintomai Jul 23, 2026
256687a
address review, simplify
catalintomai Jul 23, 2026
e85493b
simplify
catalintomai Jul 23, 2026
3e2aaa1
address comments
catalintomai Jul 24, 2026
4951f3f
address comments
catalintomai Jul 25, 2026
be22639
publish/autosave into shared store transactions; harden guards, presence
catalintomai Jul 26, 2026
81e731d
update comments + move structure to model
catalintomai Jul 28, 2026
8cfb94f
address coderabbitai comments
catalintomai Jul 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,5 @@ make check-style

## Documentation


See the [Mattermost plugin development guide](https://developers.mattermost.com/integrate/plugins/) for plugin structure, server/webapp hooks, and the release process.
180 changes: 176 additions & 4 deletions assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,6 @@
"id": "app.page.create.invalid_user_id.app_error",
"translation": "Invalid user ID."
},
{
"id": "app.page.create.search_text_without_content.app_error",
"translation": "Search text cannot be set without page content."
},
{
"id": "app.page.create.space_not_found.app_error",
"translation": "The space could not be found."
Expand Down Expand Up @@ -103,6 +99,10 @@
"id": "app.page.get_children.invalid_id.app_error",
"translation": "Invalid page ID."
},
{
"id": "app.page.invalid_content.app_error",
"translation": "The page body is not valid content."
},
{
"id": "app.page.invalid_parent.app_error",
"translation": "The destination parent page does not exist."
Expand Down Expand Up @@ -151,6 +151,14 @@
"id": "app.page.not_found.app_error",
"translation": "The page could not be found."
},
{
"id": "app.page.presence.invalid_page_id.app_error",
"translation": "Invalid page ID."
},
{
"id": "app.page.presence.invalid_space_id.app_error",
"translation": "Invalid space ID."
},
{
"id": "app.page.restore.invalid_id.app_error",
"translation": "Invalid page ID."
Expand Down Expand Up @@ -199,6 +207,158 @@
"id": "app.page.update.store_error.app_error",
"translation": "An error occurred while updating the page."
},
{
"id": "app.page_draft.create.invalid_parent.app_error",
"translation": "The parent page or draft was not found."
},
{
"id": "app.page_draft.create.invalid_parent_id.app_error",
"translation": "The parent ID is not a valid ID."
},
{
"id": "app.page_draft.create.invalid_space_id.app_error",
"translation": "Invalid space ID."
},
{
"id": "app.page_draft.create.invalid_user_id.app_error",
"translation": "Invalid user ID."
},
{
"id": "app.page_draft.create.parent_cycle.app_error",
"translation": "Setting this parent would create a cycle in the draft hierarchy."
},
{
"id": "app.page_draft.create.parent_too_deep.app_error",
"translation": "The draft hierarchy is too deep to add another level."
},
{
"id": "app.page_draft.delete.invalid_page_id.app_error",
"translation": "Invalid page ID."
},
{
"id": "app.page_draft.delete.invalid_space_id.app_error",
"translation": "Invalid space ID."
},
{
"id": "app.page_draft.delete.invalid_user_id.app_error",
"translation": "Invalid user ID."
},
{
"id": "app.page_draft.delete.not_found.app_error",
"translation": "Draft not found."
},
{
"id": "app.page_draft.get.invalid_page_id.app_error",
"translation": "Invalid page ID."
},
{
"id": "app.page_draft.get.invalid_space_id.app_error",
"translation": "Invalid space ID."
},
{
"id": "app.page_draft.get.invalid_user_id.app_error",
"translation": "Invalid user ID."
},
{
"id": "app.page_draft.get.not_found.app_error",
"translation": "Draft not found."
},
{
"id": "app.page_draft.list.invalid_space_id.app_error",
"translation": "Invalid space ID."
},
{
"id": "app.page_draft.list.invalid_user_id.app_error",
"translation": "Invalid user ID."
},
{
"id": "app.page_draft.publish.baseline_required.app_error",
"translation": "Cannot publish: the draft is missing its edit baseline. Reopen the page and try again."
},
{
"id": "app.page_draft.publish.conflict.app_error",
"translation": "The page was modified by another writer. Refresh and try again."
},
{
"id": "app.page_draft.publish.draft_changed.app_error",
"translation": "This draft was saved again while it was being published. Nothing was published; try publishing again to include your latest changes."
},
{
"id": "app.page_draft.publish.draft_not_found.app_error",
"translation": "The draft could not be found; it may have already been published or discarded."
},
{
"id": "app.page_draft.publish.edit_conflict.app_error",
"translation": "Someone else edited this page while you were writing. Reopen the page to see their changes, then publish again."
},
{
"id": "app.page_draft.publish.invalid_page_id.app_error",
"translation": "Invalid page ID."
},
{
"id": "app.page_draft.publish.invalid_space_id.app_error",
"translation": "Invalid space ID."
},
{
"id": "app.page_draft.publish.invalid_user_id.app_error",
"translation": "Invalid user ID."
},
{
"id": "app.page_draft.publish.page_deleted.app_error",
"translation": "The page was deleted and can no longer be published."
},
{
"id": "app.page_draft.publish.page_not_found.app_error",
"translation": "The page does not exist or is not accessible in this space."
},
{
"id": "app.page_draft.publish.parent_unpublished.app_error",
"translation": "The parent page must be published before this page can be published."
},
{
"id": "app.page_draft.quota_exceeded.app_error",
"translation": "Draft limit reached. Publish or discard an existing draft to free up space."
},
{
"id": "app.page_draft.update.draft_changed.app_error",
"translation": "A concurrent autosave updated the draft. Reload it and try saving again."
},
{
"id": "app.page_draft.update.edit_conflict.app_error",
"translation": "A concurrent edit has been published; please reload the page to continue editing."
},
{
"id": "app.page_draft.update.invalid_page_id.app_error",
"translation": "Invalid page ID."
},
{
"id": "app.page_draft.update.invalid_parent_id.app_error",
"translation": "Invalid parent ID."
},
{
"id": "app.page_draft.update.invalid_space_id.app_error",
"translation": "Invalid space ID."
},
{
"id": "app.page_draft.update.invalid_user_id.app_error",
"translation": "Invalid user ID."
},
{
"id": "app.page_draft.update.nil_draft.app_error",
"translation": "Draft is required."
},
{
"id": "app.page_draft.update.page_not_found.app_error",
"translation": "The page does not exist or is not accessible in this space."
},
{
"id": "app.page_draft.update.parent_cycle.app_error",
"translation": "Setting this parent would create a cycle in the draft hierarchy."
},
{
"id": "app.page_draft.update.parent_too_deep.app_error",
"translation": "The draft hierarchy is too deep to add another level."
},
{
"id": "app.shared.description_too_long.app_error",
"translation": "The description exceeds the maximum length of {{.MaxLength}} characters."
Expand Down Expand Up @@ -367,6 +527,10 @@
"id": "app.store.too_large.app_error",
"translation": "The result set is too large; narrow your request (limit {{.Limit}})."
},
{
"id": "model.draft.is_valid.base_edit_at.app_error",
"translation": "Invalid draft baseline edit time."
},
{
"id": "model.draft.is_valid.body_size.app_error",
"translation": "The draft body is too long."
Expand All @@ -375,10 +539,18 @@
"id": "model.draft.is_valid.create_at.app_error",
"translation": "Invalid draft creation time."
},
{
"id": "model.draft.is_valid.file_id.app_error",
"translation": "One or more file IDs are invalid."
},
{
"id": "model.draft.is_valid.file_ids.app_error",
"translation": "The draft has too many file attachments."
},
{
"id": "model.draft.is_valid.last_active_at.app_error",
"translation": "Invalid draft last-active time."
},
{
"id": "model.draft.is_valid.page_id.app_error",
"translation": "Invalid page ID for the draft."
Expand Down
10 changes: 5 additions & 5 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,19 @@ module github.com/mattermost/mattermost-plugin-docs

go 1.26.4

// Dev-only pins: server/public is pinned to the head of the paired core branch that adds the
// Space backing-channel type — ChannelTypeSpace ("S"), pluginapi Channel.GetChannelOfType, and
// pluginapi Channel.Restore are not yet in a released server/public.
// Dev-only pins: server/public is pinned to the master commit that merged the Space
// backing-channel type — ChannelTypeSpace ("S"), pluginapi Channel.GetChannelOfType, and
// pluginapi Channel.Restore — which is not yet in a released server/public.
// server/v8 is the test harness only (storetest helpers); it does not contribute any runtime
// symbols and is pinned independently to an older commit. The two modules live in the same
// monorepo but are versioned independently, so their pseudo-version timestamps will always
// differ; what matters is that server/public has the APIs this plugin calls.
// Bump both to a release tag once the core space-channel changes merge and ship.
// Bump both to a release tag once the core space-channel changes ship.
require (
github.com/gorilla/mux v1.8.1
github.com/jmoiron/sqlx v1.4.0
github.com/lib/pq v1.12.3
github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5
github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf
github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704
github.com/mattermost/morph v1.1.0
github.com/mattermost/squirrel v0.5.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -140,8 +140,8 @@ github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956 h1:Y1Tu/swM31pVwwb
github.com/mattermost/ldap v0.0.0-20231116144001-0f480c025956/go.mod h1:SRl30Lb7/QoYyohYeVBuqYvvmXSZJxZgiV3Zf6VbxjI=
github.com/mattermost/logr/v2 v2.0.22 h1:npFkXlkAWR9J8payh8ftPcCZvLbHSI125mAM5/r/lP4=
github.com/mattermost/logr/v2 v2.0.22/go.mod h1:0sUKpO+XNMZApeumaid7PYaUZPBIydfuWZ0dqixXo+s=
github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5 h1:L/o7nmoq4fv/wfcaen7tr85GxZ/V05hpzfpI3/TuOto=
github.com/mattermost/mattermost/server/public v0.4.4-0.20260713131524-80d5b7966dc5/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic=
github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf h1:Elv/2xLhHNauIlgA3EajAQYRzpF2OfCF77UTCmQdhX4=
github.com/mattermost/mattermost/server/public v0.4.4-0.20260716203457-5f7f967a7dbf/go.mod h1:rHFKFSnyNmyk1qieL00Fv+YuLb093Q7y8VTwBu/43ic=
github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704 h1:vEw+u4m6mUrjHgpvDLXfCkuYcpUk8Q5CorgPRsqjW74=
github.com/mattermost/mattermost/server/v8 v8.0.0-20260623200446-ba033eae4704/go.mod h1:RBaqawSPsPB76XA4hfrIRFvLS0QWF1qJWHr2+w34+6s=
github.com/mattermost/morph v1.1.0 h1:Q9vrJbeM3s2jfweGheq12EFIzdNp9a/6IovcbvOQ6Cw=
Expand Down
42 changes: 40 additions & 2 deletions server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ import (
//
// Authorization: every route requires an authenticated user via MattermostAuthorizationRequired.
// All space- and page-scoped handlers additionally gate on backing-channel membership via
// CheckSpaceMembership (implemented). Per-page role ACLs (author vs. editor within a space)
// are not yet implemented and are deferred to a follow-up.
// CheckSpaceMembership. Per-page role ACLs (author vs. editor within a space) are not yet
// implemented.
func (p *Plugin) initRouter() *mux.Router {
router := mux.NewRouter()
router.Use(p.MattermostAuthorizationRequired)
Expand Down Expand Up @@ -60,6 +60,17 @@ func (p *Plugin) initRouter() *mux.Router {
api.HandleFunc("/spaces/{space_id}/pages/{page_id}/move-to-space", p.handleMovePageToSpace).Methods(http.MethodPatch)
api.HandleFunc("/spaces/{space_id}/pages/{page_id}/duplicate", p.handleDuplicatePage).Methods(http.MethodPost)

// Draft CRUD + publish.
api.HandleFunc("/spaces/{space_id}/drafts", p.handleCreateSpaceDraft).Methods(http.MethodPost)
api.HandleFunc("/spaces/{space_id}/drafts", p.handleGetPageDraftsForSpace).Methods(http.MethodGet)
api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleUpdatePageDraft).Methods(http.MethodPatch)
api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleGetPageDraft).Methods(http.MethodGet)
api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft", p.handleDeletePageDraft).Methods(http.MethodDelete)
api.HandleFunc("/spaces/{space_id}/pages/{page_id}/draft/publish", p.handlePublishPageDraft).Methods(http.MethodPost)

// Presence.
api.HandleFunc("/spaces/{space_id}/pages/{page_id}/active-editors", p.handleGetPageActiveEditors).Methods(http.MethodGet)

return router
}

Expand Down Expand Up @@ -118,11 +129,38 @@ func (p *Plugin) writeAppError(w http.ResponseWriter, appErr *mmmodel.AppError)
if appErr.StatusCode >= http.StatusInternalServerError {
p.API.LogError("Docs API request failed", "where", appErr.Where, "id", appErr.Id, "status_code", appErr.StatusCode, "err", appErr.Error())
}
if appErr.StatusCode == http.StatusConflict {
p.writeConflictWithPage(w, appErr, nil)
return
}
safe := *appErr
safe.WipeDetailed()
writeJSON(w, appErr.StatusCode, &safe)
}

// conflictResponse is the body every 409 carries: the scrubbed AppError plus the current server
// page. One shape across all conflicts means a client parses a 409 the same way whichever endpoint
// produced it, rather than branching on the route.
//
// current_page is null when the handler has no page to offer — the conflict was not about a page, or
// the re-read that would have produced it failed — so a client treats it as an optional shortcut and
// falls back to a GET. Where it is populated (publish and page-update conflicts, which already read
// the live page to build the error) it saves that round-trip: the client diffs and re-baselines
// against the returned EditAt directly. The whole page is returned rather than a curated snapshot,
// so the client renders whatever it needs.
type conflictResponse struct {
Error *mmmodel.AppError `json:"error"`
CurrentPage *model.Page `json:"current_page"`
}

// writeConflictWithPage writes a conflictResponse using the AppError's own StatusCode (409) as the
// HTTP status. DetailedError is scrubbed first, matching writeAppError.
func (p *Plugin) writeConflictWithPage(w http.ResponseWriter, appErr *mmmodel.AppError, current *model.Page) {
safe := *appErr
safe.WipeDetailed()
writeJSON(w, appErr.StatusCode, conflictResponse{Error: &safe, CurrentPage: current})
}

// writeJSON serialises v as a JSON body with the given status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
Expand Down
Loading
Loading