diff --git a/api/go.mod b/api/go.mod
index fc8cd398..48c5490e 100644
--- a/api/go.mod
+++ b/api/go.mod
@@ -11,7 +11,7 @@ require (
github.com/minio/minio-go/v7 v7.0.98
github.com/rabbitmq/amqp091-go v1.10.0
github.com/redis/go-redis/v9 v9.17.3
- golang.org/x/crypto v0.48.0
+ golang.org/x/crypto v0.50.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
@@ -63,12 +63,12 @@ require (
go.uber.org/mock v0.5.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.20.0 // indirect
- golang.org/x/mod v0.32.0 // indirect
- golang.org/x/net v0.49.0 // indirect
- golang.org/x/sync v0.19.0 // indirect
- golang.org/x/sys v0.41.0 // indirect
- golang.org/x/text v0.34.0 // indirect
- golang.org/x/tools v0.41.0 // indirect
+ golang.org/x/mod v0.34.0 // indirect
+ golang.org/x/net v0.53.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.43.0 // indirect
+ golang.org/x/text v0.36.0 // indirect
+ golang.org/x/tools v0.43.0 // indirect
google.golang.org/protobuf v1.36.9 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
diff --git a/api/go.sum b/api/go.sum
index dbedf8b3..3f88612b 100644
--- a/api/go.sum
+++ b/api/go.sum
@@ -191,19 +191,33 @@ golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
+golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
+golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
+golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
+golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
+golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
+golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
+golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
+golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
+golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
+golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
+golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/api/internal/handler/issue.go b/api/internal/handler/issue.go
index db2756f5..28f496b0 100644
--- a/api/internal/handler/issue.go
+++ b/api/internal/handler/issue.go
@@ -434,6 +434,94 @@ func (h *IssueHandler) Delete(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+// IsSubscribed reports whether the current user is subscribed to the issue.
+// GET /api/workspaces/:slug/projects/:projectId/issues/:pk/subscribe/
+func (h *IssueHandler) IsSubscribed(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ slug := c.Param("slug")
+ projectID, err := uuid.Parse(c.Param("projectId"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
+ return
+ }
+ iid, ok := issueID(c)
+ if !ok {
+ return
+ }
+ subscribed, err := h.Issue.IsSubscribed(c.Request.Context(), slug, projectID, iid, user.ID)
+ if err != nil {
+ if err == service.ErrIssueNotFound || err == service.ErrProjectForbidden {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Issue not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check subscription"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"subscribed": subscribed})
+}
+
+// Subscribe subscribes the current user to issue activity.
+// POST /api/workspaces/:slug/projects/:projectId/issues/:pk/subscribe/
+func (h *IssueHandler) Subscribe(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ slug := c.Param("slug")
+ projectID, err := uuid.Parse(c.Param("projectId"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
+ return
+ }
+ iid, ok := issueID(c)
+ if !ok {
+ return
+ }
+ if err := h.Issue.Subscribe(c.Request.Context(), slug, projectID, iid, user.ID); err != nil {
+ if err == service.ErrIssueNotFound || err == service.ErrProjectForbidden {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Issue not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to subscribe"})
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+// Unsubscribe removes the current user's subscription.
+// DELETE /api/workspaces/:slug/projects/:projectId/issues/:pk/subscribe/
+func (h *IssueHandler) Unsubscribe(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ slug := c.Param("slug")
+ projectID, err := uuid.Parse(c.Param("projectId"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"})
+ return
+ }
+ iid, ok := issueID(c)
+ if !ok {
+ return
+ }
+ if err := h.Issue.Unsubscribe(c.Request.Context(), slug, projectID, iid, user.ID); err != nil {
+ if err == service.ErrIssueNotFound || err == service.ErrProjectForbidden {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Issue not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsubscribe"})
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
// ListActivities returns the chronological activity log for an issue.
// GET /api/workspaces/:slug/projects/:projectId/issues/:pk/activities/
func (h *IssueHandler) ListActivities(c *gin.Context) {
diff --git a/api/internal/handler/notification.go b/api/internal/handler/notification.go
index 410de278..1cd66304 100644
--- a/api/internal/handler/notification.go
+++ b/api/internal/handler/notification.go
@@ -2,9 +2,11 @@ package handler
import (
"net/http"
+ "time"
"github.com/Devlaner/devlane/api/internal/middleware"
"github.com/Devlaner/devlane/api/internal/service"
+ "github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
@@ -15,7 +17,7 @@ type NotificationHandler struct {
}
// List returns notifications for the current user in the workspace.
-// GET /api/workspaces/:slug/notifications/
+// GET /api/workspaces/:slug/notifications/?unread_only=true|false&mentions=true|false
func (h *NotificationHandler) List(c *gin.Context) {
user := middleware.GetUser(c)
if user == nil {
@@ -23,8 +25,23 @@ func (h *NotificationHandler) List(c *gin.Context) {
return
}
slug := c.Param("slug")
- unreadOnly := c.Query("unread_only") == "true"
- list, err := h.Notification.List(c.Request.Context(), slug, user.ID, unreadOnly)
+ opts := store.ListOpts{
+ UnreadOnly: c.Query("unread_only") == "true",
+ MentionsOnly: c.Query("mentions") == "true",
+ }
+ switch c.Query("archived") {
+ case "true":
+ t := true
+ opts.Archived = &t
+ // Archived view should also surface snoozed rows so users can manage
+ // them — otherwise a row that was both archived and snoozed becomes
+ // invisible until the snooze expires.
+ opts.IncludeSnoozed = true
+ case "all":
+ opts.IncludeArchived = true
+ opts.IncludeSnoozed = true
+ }
+ list, err := h.Notification.List(c.Request.Context(), slug, user.ID, opts)
if err != nil {
if err == service.ErrProjectForbidden {
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
@@ -36,6 +53,28 @@ func (h *NotificationHandler) List(c *gin.Context) {
c.JSON(http.StatusOK, list)
}
+// UnreadCount returns the number of unread notifications for the current user
+// in the workspace, with the mentions count broken out for the bell badge.
+// GET /api/workspaces/:slug/notifications/unread-count/
+func (h *NotificationHandler) UnreadCount(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ slug := c.Param("slug")
+ total, mentions, err := h.Notification.UnreadCount(c.Request.Context(), slug, user.ID)
+ if err != nil {
+ if err == service.ErrProjectForbidden {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count notifications"})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"total": total, "mentions": mentions})
+}
+
// MarkRead marks a notification as read.
// POST /api/workspaces/:slug/notifications/:id/read/
func (h *NotificationHandler) MarkRead(c *gin.Context) {
@@ -60,6 +99,139 @@ func (h *NotificationHandler) MarkRead(c *gin.Context) {
c.Status(http.StatusNoContent)
}
+// SnoozeRequest is the body for the Snooze endpoint.
+type SnoozeRequest struct {
+ Until time.Time `json:"until"`
+}
+
+// Snooze hides a notification until the given timestamp.
+// POST /api/workspaces/:slug/notifications/:id/snooze/ body: {"until": "2026-05-06T09:00:00Z"}
+func (h *NotificationHandler) Snooze(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
+ return
+ }
+ var req SnoozeRequest
+ if err := c.ShouldBindJSON(&req); err != nil || req.Until.IsZero() {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
+ return
+ }
+ if err := h.Notification.Snooze(c.Request.Context(), id, user.ID, req.Until); err != nil {
+ switch err {
+ case service.ErrProjectForbidden:
+ c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
+ case service.ErrInvalidSnooze:
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Snooze time must be in the future"})
+ default:
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to snooze"})
+ }
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+// Unsnooze clears a notification's snooze.
+// DELETE /api/workspaces/:slug/notifications/:id/snooze/
+func (h *NotificationHandler) Unsnooze(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
+ return
+ }
+ if err := h.Notification.Unsnooze(c.Request.Context(), id, user.ID); err != nil {
+ if err == service.ErrProjectForbidden {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unsnooze"})
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+// Archive flags a notification as archived for the receiver.
+// POST /api/workspaces/:slug/notifications/:id/archive/
+func (h *NotificationHandler) Archive(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
+ return
+ }
+ if err := h.Notification.Archive(c.Request.Context(), id, user.ID); err != nil {
+ if err == service.ErrProjectForbidden {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to archive"})
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+// Unarchive restores an archived notification.
+// DELETE /api/workspaces/:slug/notifications/:id/archive/
+func (h *NotificationHandler) Unarchive(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
+ return
+ }
+ if err := h.Notification.Unarchive(c.Request.Context(), id, user.ID); err != nil {
+ if err == service.ErrProjectForbidden {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to unarchive"})
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+// MarkUnread re-flags a previously-read notification.
+// DELETE /api/workspaces/:slug/notifications/:id/read/
+func (h *NotificationHandler) MarkUnread(c *gin.Context) {
+ user := middleware.GetUser(c)
+ if user == nil {
+ c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return
+ }
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification ID"})
+ return
+ }
+ if err := h.Notification.MarkUnread(c.Request.Context(), id, user.ID); err != nil {
+ if err == service.ErrProjectForbidden {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark unread"})
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
// MarkAllRead marks all workspace notifications as read for the current user.
// POST /api/workspaces/:slug/notifications/mark-all-read/
func (h *NotificationHandler) MarkAllRead(c *gin.Context) {
diff --git a/api/internal/handler/page.go b/api/internal/handler/page.go
index 5dab97dd..5aa8f386 100644
--- a/api/internal/handler/page.go
+++ b/api/internal/handler/page.go
@@ -1,42 +1,126 @@
package handler
import (
+ "encoding/json"
+ "errors"
"net/http"
"github.com/Devlaner/devlane/api/internal/middleware"
+ "github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/service"
+ "github.com/Devlaner/devlane/api/internal/store"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
-// PageHandler serves pages (workspace or project scoped).
+// PageHandler serves project / workspace pages and their content/version actions.
type PageHandler struct {
Page *service.PageService
}
-// List returns pages; optional project_id filters by project.
-// GET /api/workspaces/:slug/pages/
-func (h *PageHandler) List(c *gin.Context) {
+func (h *PageHandler) requireUser(c *gin.Context) (uuid.UUID, bool) {
user := middleware.GetUser(c)
if user == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ return uuid.Nil, false
+ }
+ return user.ID, true
+}
+
+func parsePageID(c *gin.Context) (uuid.UUID, bool) {
+ id, err := uuid.Parse(c.Param("pageId"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page ID"})
+ return uuid.Nil, false
+ }
+ return id, true
+}
+
+// translatePageError maps service errors to HTTP responses. 404 hides the
+// difference between "missing" and "no permission" to avoid existence leaks.
+func translatePageError(c *gin.Context, err error, fallback string) {
+ switch {
+ case errors.Is(err, service.ErrPageNotFound),
+ errors.Is(err, service.ErrProjectForbidden),
+ errors.Is(err, service.ErrProjectNotFound):
+ c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
+ case errors.Is(err, service.ErrPageReadOnly):
+ c.JSON(http.StatusForbidden, gin.H{"error": "No permission to edit this page"})
+ case errors.Is(err, service.ErrPageLocked):
+ c.JSON(http.StatusConflict, gin.H{"error": "Page is locked"})
+ case errors.Is(err, service.ErrPageArchived):
+ c.JSON(http.StatusConflict, gin.H{"error": "Page is archived"})
+ case errors.Is(err, service.ErrPageNotArchived):
+ c.JSON(http.StatusConflict, gin.H{"error": "Page must be archived before deletion"})
+ case errors.Is(err, service.ErrPageBadParent):
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid parent page"})
+ case errors.Is(err, service.ErrPageBadRequest):
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request"})
+ default:
+ c.JSON(http.StatusInternalServerError, gin.H{"error": fallback})
+ }
+}
+
+// List returns pages.
+// GET /api/workspaces/:slug/pages/?project_id&parent_id&archived=true|false&search&owned_by_me=true
+func (h *PageHandler) List(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
return
}
slug := c.Param("slug")
+
var projectID *uuid.UUID
if p := c.Query("project_id"); p != "" {
- id, err := uuid.Parse(p)
- if err == nil {
+ if id, err := uuid.Parse(p); err == nil {
projectID = &id
}
}
- list, err := h.Page.List(c.Request.Context(), slug, projectID, user.ID)
- if err != nil {
- if err == service.ErrProjectForbidden || err == service.ErrProjectNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
- return
+
+ opts := store.ListPagesOpts{}
+ switch c.Query("archived") {
+ case "true":
+ t := true
+ opts.Archived = &t
+ case "false":
+ f := false
+ opts.Archived = &f
+ }
+ if p := c.Query("parent_id"); p != "" {
+ if id, err := uuid.Parse(p); err == nil {
+ opts.ParentID = &id
}
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list pages"})
+ } else if c.Query("only_roots") == "true" {
+ opts.OnlyRoots = true
+ }
+ if c.Query("owned_by_me") == "true" {
+ opts.OwnerID = &userID
+ }
+ opts.Search = c.Query("search")
+
+ list, err := h.Page.List(c.Request.Context(), slug, projectID, userID, opts)
+ if err != nil {
+ translatePageError(c, err, "Failed to list pages")
+ return
+ }
+ c.JSON(http.StatusOK, list)
+}
+
+// ListChildren returns immediate sub-pages of a page.
+// GET /api/workspaces/:slug/pages/:pageId/children/
+func (h *PageHandler) ListChildren(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
+ return
+ }
+ slug := c.Param("slug")
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ list, err := h.Page.ListChildren(c.Request.Context(), slug, pageID, userID)
+ if err != nil {
+ translatePageError(c, err, "Failed to list sub-pages")
return
}
c.JSON(http.StatusOK, list)
@@ -45,113 +129,343 @@ func (h *PageHandler) List(c *gin.Context) {
// Create creates a page.
// POST /api/workspaces/:slug/pages/
func (h *PageHandler) Create(c *gin.Context) {
- user := middleware.GetUser(c)
- if user == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ userID, ok := h.requireUser(c)
+ if !ok {
return
}
slug := c.Param("slug")
var body struct {
- Name string `json:"name" binding:"required"`
+ Name string `json:"name"`
DescriptionHTML string `json:"description_html"`
ProjectID *uuid.UUID `json:"project_id"`
- Access int16 `json:"access"` // 0 public, 1 private
+ ParentID *uuid.UUID `json:"parent_id"`
+ Access int16 `json:"access"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
return
}
- page, err := h.Page.Create(c.Request.Context(), slug, body.ProjectID, user.ID, body.Name, body.DescriptionHTML, body.Access)
+ page, err := h.Page.Create(c.Request.Context(), slug, body.ProjectID, userID, body.Name, body.DescriptionHTML, body.Access, body.ParentID)
if err != nil {
- if err == service.ErrProjectForbidden || err == service.ErrProjectNotFound {
- c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create page"})
+ translatePageError(c, err, "Failed to create page")
return
}
c.JSON(http.StatusCreated, page)
}
-// Get returns a page by id.
+// Get returns a page.
// GET /api/workspaces/:slug/pages/:pageId/
func (h *PageHandler) Get(c *gin.Context) {
- user := middleware.GetUser(c)
- if user == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ userID, ok := h.requireUser(c)
+ if !ok {
return
}
slug := c.Param("slug")
- pageID, err := uuid.Parse(c.Param("pageId"))
- if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page ID"})
+ pageID, ok := parsePageID(c)
+ if !ok {
return
}
- page, err := h.Page.Get(c.Request.Context(), slug, pageID, user.ID)
+ page, err := h.Page.Get(c.Request.Context(), slug, pageID, userID)
if err != nil {
- if err == service.ErrPageNotFound || err == service.ErrProjectForbidden {
- c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get page"})
+ translatePageError(c, err, "Failed to get page")
return
}
c.JSON(http.StatusOK, page)
}
-// Update updates a page.
+// UpdateMeta updates name / access / parent / logo. Owner-only.
// PATCH /api/workspaces/:slug/pages/:pageId/
-func (h *PageHandler) Update(c *gin.Context) {
- user := middleware.GetUser(c)
- if user == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+//
+// `logo_props` follows tri-state semantics: omitted = leave untouched;
+// `null` = clear; object = replace. Gin's bind layer flattens missing fields
+// to the zero value, so we use a `json.RawMessage` to disambiguate.
+func (h *PageHandler) UpdateMeta(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
return
}
slug := c.Param("slug")
- pageID, err := uuid.Parse(c.Param("pageId"))
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ var body struct {
+ Name *string `json:"name"`
+ Access *int16 `json:"access"`
+ ParentID *uuid.UUID `json:"parent_id"`
+ ClearParent bool `json:"clear_parent"`
+ LogoProps json.RawMessage `json:"logo_props"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
+ return
+ }
+ in := service.PageMetaUpdate{
+ Name: body.Name,
+ Access: body.Access,
+ ParentID: body.ParentID,
+ ClearParent: body.ClearParent,
+ }
+ if len(body.LogoProps) > 0 {
+ in.SetLogoProps = true
+ // Treat the JSON literal `null` as an explicit clear.
+ if string(body.LogoProps) != "null" {
+ var props model.JSONMap
+ if err := json.Unmarshal(body.LogoProps, &props); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid logo_props", "detail": err.Error()})
+ return
+ }
+ in.LogoProps = props
+ }
+ }
+ page, err := h.Page.UpdateMeta(c.Request.Context(), slug, pageID, userID, in)
if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page ID"})
+ translatePageError(c, err, "Failed to update page")
+ return
+ }
+ c.JSON(http.StatusOK, page)
+}
+
+// UpdateContent autosaves the body HTML.
+// PATCH /api/workspaces/:slug/pages/:pageId/content/
+func (h *PageHandler) UpdateContent(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
return
}
+ slug := c.Param("slug")
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ // description_html is a *string + binding:"required" so an absent field
+ // fails validation (returns 400) but an explicit empty string is allowed —
+ // users may legitimately want to clear the body.
var body struct {
- Name string `json:"name"`
- DescriptionHTML string `json:"description_html"`
- Access *int16 `json:"access"`
+ DescriptionHTML *string `json:"description_html" binding:"required"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request", "detail": err.Error()})
+ return
}
- _ = c.ShouldBindJSON(&body)
- page, err := h.Page.Update(c.Request.Context(), slug, pageID, user.ID, body.Name, body.DescriptionHTML, body.Access)
+ page, err := h.Page.UpdateContent(c.Request.Context(), slug, pageID, userID, *body.DescriptionHTML)
if err != nil {
- if err == service.ErrPageNotFound || err == service.ErrProjectForbidden {
- c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update page"})
+ translatePageError(c, err, "Failed to save")
return
}
c.JSON(http.StatusOK, page)
}
-// Delete deletes a page.
+// Lock / Unlock — owner-only.
+// POST/DELETE /api/workspaces/:slug/pages/:pageId/lock/
+func (h *PageHandler) Lock(c *gin.Context) { h.lockToggle(c, true) }
+func (h *PageHandler) Unlock(c *gin.Context) { h.lockToggle(c, false) }
+
+func (h *PageHandler) lockToggle(c *gin.Context, lock bool) {
+ userID, ok := h.requireUser(c)
+ if !ok {
+ return
+ }
+ slug := c.Param("slug")
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ var err error
+ if lock {
+ err = h.Page.Lock(c.Request.Context(), slug, pageID, userID)
+ } else {
+ err = h.Page.Unlock(c.Request.Context(), slug, pageID, userID)
+ }
+ if err != nil {
+ translatePageError(c, err, "Failed to update lock")
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+// Archive / Unarchive — owner-only. Archive cascades to descendants.
+// POST/DELETE /api/workspaces/:slug/pages/:pageId/archive/
+func (h *PageHandler) Archive(c *gin.Context) { h.archiveToggle(c, true) }
+func (h *PageHandler) Unarchive(c *gin.Context) { h.archiveToggle(c, false) }
+
+func (h *PageHandler) archiveToggle(c *gin.Context, archive bool) {
+ userID, ok := h.requireUser(c)
+ if !ok {
+ return
+ }
+ slug := c.Param("slug")
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ var err error
+ if archive {
+ err = h.Page.Archive(c.Request.Context(), slug, pageID, userID)
+ } else {
+ err = h.Page.Unarchive(c.Request.Context(), slug, pageID, userID)
+ }
+ if err != nil {
+ translatePageError(c, err, "Failed to update archive")
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+// Delete — owner-only, archived-only.
// DELETE /api/workspaces/:slug/pages/:pageId/
func (h *PageHandler) Delete(c *gin.Context) {
- user := middleware.GetUser(c)
- if user == nil {
- c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
+ userID, ok := h.requireUser(c)
+ if !ok {
+ return
+ }
+ slug := c.Param("slug")
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ if err := h.Page.Delete(c.Request.Context(), slug, pageID, userID); err != nil {
+ translatePageError(c, err, "Failed to delete page")
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+// Duplicate — any viewer can duplicate; new page is owned by caller.
+// POST /api/workspaces/:slug/pages/:pageId/duplicate/
+func (h *PageHandler) Duplicate(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
+ return
+ }
+ slug := c.Param("slug")
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ page, err := h.Page.Duplicate(c.Request.Context(), slug, pageID, userID)
+ if err != nil {
+ translatePageError(c, err, "Failed to duplicate page")
+ return
+ }
+ c.JSON(http.StatusCreated, page)
+}
+
+// ListVersions / GetVersion / RestoreVersion
+// GET /api/workspaces/:slug/pages/:pageId/versions/
+// GET /api/workspaces/:slug/pages/:pageId/versions/:versionId/
+// POST /api/workspaces/:slug/pages/:pageId/versions/:versionId/restore/
+func (h *PageHandler) ListVersions(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
return
}
slug := c.Param("slug")
- pageID, err := uuid.Parse(c.Param("pageId"))
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ list, err := h.Page.ListVersions(c.Request.Context(), slug, pageID, userID)
if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid page ID"})
+ translatePageError(c, err, "Failed to list versions")
return
}
- if err := h.Page.Delete(c.Request.Context(), slug, pageID, user.ID); err != nil {
- if err == service.ErrPageNotFound || err == service.ErrProjectForbidden {
- c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
- return
- }
- c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete page"})
+ c.JSON(http.StatusOK, list)
+}
+
+func (h *PageHandler) GetVersion(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
+ return
+ }
+ slug := c.Param("slug")
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ versionID, err := uuid.Parse(c.Param("versionId"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
+ return
+ }
+ v, err := h.Page.GetVersion(c.Request.Context(), slug, pageID, versionID, userID)
+ if err != nil {
+ translatePageError(c, err, "Failed to get version")
+ return
+ }
+ c.JSON(http.StatusOK, v)
+}
+
+func (h *PageHandler) RestoreVersion(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
+ return
+ }
+ slug := c.Param("slug")
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ versionID, err := uuid.Parse(c.Param("versionId"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid version ID"})
+ return
+ }
+ page, err := h.Page.RestoreVersion(c.Request.Context(), slug, pageID, versionID, userID)
+ if err != nil {
+ translatePageError(c, err, "Failed to restore version")
+ return
+ }
+ c.JSON(http.StatusOK, page)
+}
+
+// AddFavorite / RemoveFavorite / ListFavorites
+// POST/DELETE /api/workspaces/:slug/pages/:pageId/favorite/
+// GET /api/workspaces/:slug/pages/favorites/
+func (h *PageHandler) AddFavorite(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
+ return
+ }
+ slug := c.Param("slug")
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ if err := h.Page.AddFavorite(c.Request.Context(), slug, pageID, userID); err != nil {
+ translatePageError(c, err, "Failed to favorite page")
+ return
+ }
+ c.Status(http.StatusNoContent)
+}
+
+func (h *PageHandler) RemoveFavorite(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
+ return
+ }
+ slug := c.Param("slug")
+ pageID, ok := parsePageID(c)
+ if !ok {
+ return
+ }
+ if err := h.Page.RemoveFavorite(c.Request.Context(), slug, pageID, userID); err != nil {
+ translatePageError(c, err, "Failed to unfavorite page")
return
}
c.Status(http.StatusNoContent)
}
+
+func (h *PageHandler) ListFavorites(c *gin.Context) {
+ userID, ok := h.requireUser(c)
+ if !ok {
+ return
+ }
+ slug := c.Param("slug")
+ ids, err := h.Page.ListFavoriteIDs(c.Request.Context(), slug, userID)
+ if err != nil {
+ translatePageError(c, err, "Failed to list favorites")
+ return
+ }
+ c.JSON(http.StatusOK, ids)
+}
diff --git a/api/internal/model/issue_subscriber.go b/api/internal/model/issue_subscriber.go
new file mode 100644
index 00000000..28422f69
--- /dev/null
+++ b/api/internal/model/issue_subscriber.go
@@ -0,0 +1,35 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// IssueSubscriber matches issue_subscribers — users following an issue's
+// activity stream. Assignees, commenters, and mention targets are auto-subscribed
+// on action; users may also subscribe/unsubscribe manually.
+//
+// The DB has a UNIQUE(issue_id, subscriber_id) constraint, so re-subscribing
+// the same user must use ON CONFLICT DO NOTHING (or be guarded by a SELECT).
+type IssueSubscriber struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ IssueID uuid.UUID `gorm:"type:uuid;not null" json:"issue_id"`
+ SubscriberID uuid.UUID `gorm:"type:uuid;not null" json:"subscriber_id"`
+ ProjectID uuid.UUID `gorm:"type:uuid;not null" json:"project_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func (IssueSubscriber) TableName() string { return "issue_subscribers" }
+
+func (s *IssueSubscriber) BeforeCreate(tx *gorm.DB) error {
+ if s.ID == uuid.Nil {
+ s.ID = uuid.New()
+ }
+ return nil
+}
diff --git a/api/internal/model/notification.go b/api/internal/model/notification.go
index 9f481ca8..a56a1c55 100644
--- a/api/internal/model/notification.go
+++ b/api/internal/model/notification.go
@@ -8,10 +8,20 @@ import (
)
// Notification matches notifications.
+//
+// Sender values used by the in-app notification service:
+// - "assigned" — receiver was assigned to the issue
+// - "mentioned" — receiver was @-mentioned in description or comment
+// - "commented" — comment added on an issue the receiver follows
+// - "state_changed" — issue state moved
+// - "subscribed" — generic field change on an issue the receiver follows
+//
+// EntityName is "issue" for all issue-related rows. Reserve other values for future entities.
type Notification struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
Title string `gorm:"type:text;not null" json:"title"`
Message JSONMap `gorm:"type:jsonb;serializer:json" json:"message,omitempty"`
+ Sender string `gorm:"type:varchar(255);default:'system'" json:"sender,omitempty"`
ReceiverID uuid.UUID `gorm:"type:uuid;not null" json:"receiver_id"`
WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
ProjectID *uuid.UUID `gorm:"type:uuid" json:"project_id,omitempty"`
@@ -19,6 +29,7 @@ type Notification struct {
EntityIdentifier *uuid.UUID `gorm:"type:uuid" json:"entity_identifier,omitempty"`
EntityName string `gorm:"type:varchar(255)" json:"entity_name,omitempty"`
ReadAt *time.Time `gorm:"type:timestamptz" json:"read_at,omitempty"`
+ SnoozedTill *time.Time `gorm:"type:timestamptz" json:"snoozed_till,omitempty"`
ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -35,3 +46,14 @@ func (n *Notification) BeforeCreate(tx *gorm.DB) error {
}
return nil
}
+
+// Sender values for in-app notifications.
+const (
+ NotificationSenderAssigned = "assigned"
+ NotificationSenderMentioned = "mentioned"
+ NotificationSenderCommented = "commented"
+ NotificationSenderStateChanged = "state_changed"
+ NotificationSenderSubscribed = "subscribed"
+
+ NotificationEntityIssue = "issue"
+)
diff --git a/api/internal/model/page.go b/api/internal/model/page.go
index d8285542..4f47f66f 100644
--- a/api/internal/model/page.go
+++ b/api/internal/model/page.go
@@ -7,23 +7,36 @@ import (
"gorm.io/gorm"
)
+// Page access values.
+const (
+ PageAccessPublic int16 = 0
+ PageAccessPrivate int16 = 1
+)
+
// Page matches pages.
type Page struct {
- ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
- Name string `gorm:"type:text" json:"name"`
- DescriptionHTML string `gorm:"column:description_html;type:text;default:
" json:"description_html,omitempty"`
- OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"`
- WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
- Access int16 `gorm:"default:0" json:"access"`
- ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"`
- ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"`
- IsLocked bool `gorm:"column:is_locked;default:false" json:"is_locked"`
- SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
- DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
- CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
- UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ Name string `gorm:"type:text" json:"name"`
+ DescriptionHTML string `gorm:"column:description_html;type:text;default:" json:"description_html,omitempty"`
+ OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ Access int16 `gorm:"default:0" json:"access"`
+ Color string `gorm:"type:varchar(255);default:''" json:"color,omitempty"`
+ ParentID *uuid.UUID `gorm:"type:uuid" json:"parent_id,omitempty"`
+ ArchivedAt *time.Time `gorm:"type:timestamptz" json:"archived_at,omitempty"`
+ IsLocked bool `gorm:"column:is_locked;default:false" json:"is_locked"`
+ ViewProps JSONMap `gorm:"column:view_props;type:jsonb;serializer:json" json:"view_props,omitempty"`
+ LogoProps JSONMap `gorm:"column:logo_props;type:jsonb;serializer:json" json:"logo_props,omitempty"`
+ IsGlobal bool `gorm:"column:is_global;default:false" json:"is_global"`
+ MovedToPage *uuid.UUID `gorm:"column:moved_to_page;type:uuid" json:"moved_to_page,omitempty"`
+ MovedToProject *uuid.UUID `gorm:"column:moved_to_project;type:uuid" json:"moved_to_project,omitempty"`
+ SortOrder float64 `gorm:"column:sort_order;default:65535" json:"sort_order"`
+
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
+ CreatedByID *uuid.UUID `gorm:"type:uuid" json:"created_by_id,omitempty"`
+ UpdatedByID *uuid.UUID `gorm:"type:uuid" json:"updated_by_id,omitempty"`
}
func (Page) TableName() string { return "pages" }
diff --git a/api/internal/model/page_version.go b/api/internal/model/page_version.go
new file mode 100644
index 00000000..801bc94d
--- /dev/null
+++ b/api/internal/model/page_version.go
@@ -0,0 +1,37 @@
+package model
+
+import (
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+)
+
+// PageVersion matches page_versions — an immutable snapshot recorded on every
+// content save so users can browse history and restore.
+type PageVersion struct {
+ ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id"`
+ PageID uuid.UUID `gorm:"type:uuid;not null" json:"page_id"`
+ WorkspaceID uuid.UUID `gorm:"type:uuid;not null" json:"workspace_id"`
+ OwnedByID uuid.UUID `gorm:"type:uuid;not null" json:"owned_by_id"`
+ LastSavedAt time.Time `gorm:"column:last_saved_at;not null" json:"last_saved_at"`
+ DescriptionBinary []byte `gorm:"column:description_binary;type:bytea" json:"-"`
+ DescriptionHTML string `gorm:"column:description_html;type:text;default:" json:"description_html,omitempty"`
+ DescriptionStripped string `gorm:"column:description_stripped;type:text" json:"description_stripped,omitempty"`
+ DescriptionJSON JSONMap `gorm:"column:description_json;type:jsonb;serializer:json" json:"description_json,omitempty"`
+ SubPagesData JSONMap `gorm:"column:sub_pages_data;type:jsonb;serializer:json" json:"sub_pages_data,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+}
+
+func (PageVersion) TableName() string { return "page_versions" }
+
+func (v *PageVersion) BeforeCreate(tx *gorm.DB) error {
+ if v.ID == uuid.Nil {
+ v.ID = uuid.New()
+ }
+ if v.LastSavedAt.IsZero() {
+ v.LastSavedAt = time.Now()
+ }
+ return nil
+}
diff --git a/api/internal/router/router.go b/api/internal/router/router.go
index 7129d026..6eebd52b 100644
--- a/api/internal/router/router.go
+++ b/api/internal/router/router.go
@@ -67,6 +67,7 @@ func New(cfg Config) *gin.Engine {
issueViewStore := store.NewIssueViewStore(cfg.DB)
pageStore := store.NewPageStore(cfg.DB)
notificationStore := store.NewNotificationStore(cfg.DB)
+ issueSubscriberStore := store.NewIssueSubscriberStore(cfg.DB)
commentStore := store.NewCommentStore(cfg.DB)
instanceSettingStore := store.NewInstanceSettingStore(cfg.DB)
workspaceUserLinkStore := store.NewWorkspaceUserLinkStore(cfg.DB)
@@ -134,10 +135,18 @@ func New(cfg Config) *gin.Engine {
moduleSvc := service.NewModuleService(moduleStore, projectStore, workspaceStore)
issueViewSvc := service.NewIssueViewService(issueViewStore, projectStore, workspaceStore, userFavoriteStore)
pageSvc := service.NewPageService(pageStore, projectStore, workspaceStore)
- notificationSvc := service.NewNotificationService(notificationStore, workspaceStore)
+ pageSvc.SetFavoriteStore(userFavoriteStore)
+ notificationSvc := service.NewNotificationService(notificationStore, workspaceStore, issueStore, projectStore, userStore, stateStore)
+ notificationSvc.SetLogger(cfg.Log)
+ notificationSvc.SetSubscriberStore(issueSubscriberStore)
+ notificationSvc.SetPreferenceStore(userNotifPrefStore)
+ issueSvc.SetNotificationService(notificationSvc)
+ issueSvc.SetSubscriberStore(issueSubscriberStore)
commentReactionStore := store.NewCommentReactionStore(cfg.DB)
commentSvc := service.NewCommentService(commentStore, issueStore, projectStore, workspaceStore)
commentSvc.SetReactionStore(commentReactionStore)
+ commentSvc.SetNotificationService(notificationSvc)
+ commentSvc.SetSubscriberStore(issueSubscriberStore)
workspaceLinkSvc := service.NewWorkspaceLinkService(workspaceUserLinkStore, workspaceStore)
stickySvc := service.NewStickyService(stickyStore, workspaceStore)
recentVisitSvc := service.NewRecentVisitService(userRecentVisitStore, workspaceStore, issueStore, projectStore, pageStore)
@@ -291,6 +300,9 @@ func New(cfg Config) *gin.Engine {
api.PUT("/workspaces/:slug/projects/:projectId/issues/:pk/assignees/", issueHandler.ReplaceAssignees)
api.DELETE("/workspaces/:slug/projects/:projectId/issues/:pk/assignees/:assigneeId/", issueHandler.RemoveAssignee)
api.GET("/workspaces/:slug/projects/:projectId/issues/:pk/activities/", issueHandler.ListActivities)
+ api.GET("/workspaces/:slug/projects/:projectId/issues/:pk/subscribe/", issueHandler.IsSubscribed)
+ api.POST("/workspaces/:slug/projects/:projectId/issues/:pk/subscribe/", issueHandler.Subscribe)
+ api.DELETE("/workspaces/:slug/projects/:projectId/issues/:pk/subscribe/", issueHandler.Unsubscribe)
api.GET("/workspaces/:slug/projects/:projectId/cycles/", cycleHandler.List)
api.POST("/workspaces/:slug/projects/:projectId/cycles/", cycleHandler.Create)
@@ -326,13 +338,32 @@ func New(cfg Config) *gin.Engine {
api.GET("/workspaces/:slug/pages/", pageHandler.List)
api.POST("/workspaces/:slug/pages/", pageHandler.Create)
+ api.GET("/workspaces/:slug/pages/favorites/", pageHandler.ListFavorites)
api.GET("/workspaces/:slug/pages/:pageId/", pageHandler.Get)
- api.PATCH("/workspaces/:slug/pages/:pageId/", pageHandler.Update)
+ api.PATCH("/workspaces/:slug/pages/:pageId/", pageHandler.UpdateMeta)
api.DELETE("/workspaces/:slug/pages/:pageId/", pageHandler.Delete)
+ api.GET("/workspaces/:slug/pages/:pageId/children/", pageHandler.ListChildren)
+ api.PATCH("/workspaces/:slug/pages/:pageId/content/", pageHandler.UpdateContent)
+ api.POST("/workspaces/:slug/pages/:pageId/lock/", pageHandler.Lock)
+ api.DELETE("/workspaces/:slug/pages/:pageId/lock/", pageHandler.Unlock)
+ api.POST("/workspaces/:slug/pages/:pageId/archive/", pageHandler.Archive)
+ api.DELETE("/workspaces/:slug/pages/:pageId/archive/", pageHandler.Unarchive)
+ api.POST("/workspaces/:slug/pages/:pageId/duplicate/", pageHandler.Duplicate)
+ api.GET("/workspaces/:slug/pages/:pageId/versions/", pageHandler.ListVersions)
+ api.GET("/workspaces/:slug/pages/:pageId/versions/:versionId/", pageHandler.GetVersion)
+ api.POST("/workspaces/:slug/pages/:pageId/versions/:versionId/restore/", pageHandler.RestoreVersion)
+ api.POST("/workspaces/:slug/pages/:pageId/favorite/", pageHandler.AddFavorite)
+ api.DELETE("/workspaces/:slug/pages/:pageId/favorite/", pageHandler.RemoveFavorite)
api.GET("/workspaces/:slug/notifications/", notificationHandler.List)
+ api.GET("/workspaces/:slug/notifications/unread-count/", notificationHandler.UnreadCount)
api.POST("/workspaces/:slug/notifications/mark-all-read/", notificationHandler.MarkAllRead)
api.POST("/workspaces/:slug/notifications/:id/read/", notificationHandler.MarkRead)
+ api.DELETE("/workspaces/:slug/notifications/:id/read/", notificationHandler.MarkUnread)
+ api.POST("/workspaces/:slug/notifications/:id/archive/", notificationHandler.Archive)
+ api.DELETE("/workspaces/:slug/notifications/:id/archive/", notificationHandler.Unarchive)
+ api.POST("/workspaces/:slug/notifications/:id/snooze/", notificationHandler.Snooze)
+ api.DELETE("/workspaces/:slug/notifications/:id/snooze/", notificationHandler.Unsnooze)
api.GET("/workspaces/:slug/quick-links/", workspaceLinkHandler.List)
api.POST("/workspaces/:slug/quick-links/", workspaceLinkHandler.Create)
diff --git a/api/internal/service/comment.go b/api/internal/service/comment.go
index 2f1f07b0..dc7b4268 100644
--- a/api/internal/service/comment.go
+++ b/api/internal/service/comment.go
@@ -6,6 +6,7 @@ import (
"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/store"
+ "github.com/Devlaner/devlane/api/internal/text"
"github.com/google/uuid"
)
@@ -18,6 +19,8 @@ type CommentService struct {
ps *store.ProjectStore
ws *store.WorkspaceStore
reactions *store.CommentReactionStore // optional — set via SetReactionStore
+ notify *NotificationService // optional — set via SetNotificationService
+ subs *store.IssueSubscriberStore // optional — auto-subscribe commenter & mentions
}
func NewCommentService(cs *store.CommentStore, is *store.IssueStore, ps *store.ProjectStore, ws *store.WorkspaceStore) *CommentService {
@@ -27,6 +30,31 @@ func NewCommentService(cs *store.CommentStore, is *store.IssueStore, ps *store.P
// SetReactionStore wires per-comment reactions support. Optional.
func (s *CommentService) SetReactionStore(r *store.CommentReactionStore) { s.reactions = r }
+// SetNotificationService injects the notification fan-out service. Optional —
+// when nil, comments do not emit notifications.
+func (s *CommentService) SetNotificationService(n *NotificationService) { s.notify = n }
+
+// SetSubscriberStore injects the issue-subscriber store so commenters and
+// mention targets are auto-subscribed when a comment is posted. Optional.
+func (s *CommentService) SetSubscriberStore(subs *store.IssueSubscriberStore) { s.subs = subs }
+
+func (s *CommentService) autoSubscribe(ctx context.Context, issue *model.Issue, userIDs []uuid.UUID) {
+ if s.subs == nil || issue == nil {
+ return
+ }
+ for _, uid := range userIDs {
+ if uid == uuid.Nil {
+ continue
+ }
+ _ = s.subs.Subscribe(ctx, &model.IssueSubscriber{
+ IssueID: issue.ID,
+ SubscriberID: uid,
+ ProjectID: issue.ProjectID,
+ WorkspaceID: issue.WorkspaceID,
+ })
+ }
+}
+
func (s *CommentService) ensureProjectAccess(ctx context.Context, workspaceSlug string, projectID uuid.UUID, userID uuid.UUID) error {
wrk, err := s.ws.GetBySlug(ctx, workspaceSlug)
if err != nil {
@@ -80,6 +108,14 @@ func (s *CommentService) Create(ctx context.Context, workspaceSlug string, proje
if err := s.cs.Create(ctx, c); err != nil {
return nil, err
}
+ mentioned := text.ParseMentionUserIDs(comment)
+ // Auto-subscribe the commenter and any mentioned users so they pick up
+ // future activity on the issue.
+ subscribers := append([]uuid.UUID{userID}, mentioned...)
+ s.autoSubscribe(ctx, issue, subscribers)
+ if s.notify != nil {
+ s.notify.IssueCommented(ctx, issue, userID, comment, mentioned)
+ }
return c, nil
}
diff --git a/api/internal/service/issue.go b/api/internal/service/issue.go
index c70d362e..b7cfa1be 100644
--- a/api/internal/service/issue.go
+++ b/api/internal/service/issue.go
@@ -7,6 +7,7 @@ import (
"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/store"
+ "github.com/Devlaner/devlane/api/internal/text"
"github.com/google/uuid"
"gorm.io/gorm"
)
@@ -20,7 +21,9 @@ type IssueService struct {
is *store.IssueStore
ps *store.ProjectStore
ws *store.WorkspaceStore
- activity *store.IssueActivityStore // optional — may be nil
+ activity *store.IssueActivityStore // optional — may be nil
+ notify *NotificationService // optional — may be nil
+ subs *store.IssueSubscriberStore // optional — auto-subscribe assignees/mentions
}
func NewIssueService(is *store.IssueStore, ps *store.ProjectStore, ws *store.WorkspaceStore) *IssueService {
@@ -31,6 +34,34 @@ func NewIssueService(is *store.IssueStore, ps *store.ProjectStore, ws *store.Wor
// Optional — left as a setter so existing callers don't need to change.
func (s *IssueService) SetActivityStore(a *store.IssueActivityStore) { s.activity = a }
+// SetNotificationService injects the notification fan-out service. Optional —
+// when nil, no notifications are emitted from issue operations.
+func (s *IssueService) SetNotificationService(n *NotificationService) { s.notify = n }
+
+// SetSubscriberStore injects the issue-subscriber store so assignees and mention
+// targets are auto-subscribed when they're added to an issue. Optional.
+func (s *IssueService) SetSubscriberStore(subs *store.IssueSubscriberStore) { s.subs = subs }
+
+// autoSubscribe is a fire-and-forget helper used by the assignee and mention
+// hooks. Errors are logged-and-ignored — the user's primary action must not
+// fail because of a subscription bookkeeping issue.
+func (s *IssueService) autoSubscribe(ctx context.Context, issue *model.Issue, userIDs []uuid.UUID) {
+ if s.subs == nil || issue == nil {
+ return
+ }
+ for _, uid := range userIDs {
+ if uid == uuid.Nil {
+ continue
+ }
+ _ = s.subs.Subscribe(ctx, &model.IssueSubscriber{
+ IssueID: issue.ID,
+ SubscriberID: uid,
+ ProjectID: issue.ProjectID,
+ WorkspaceID: issue.WorkspaceID,
+ })
+ }
+}
+
// recordActivity inserts one issue_activities row. Errors are logged-and-ignored
// — we never fail an issue update because the activity write fails.
func (s *IssueService) recordActivity(ctx context.Context, issue *model.Issue, userID uuid.UUID, field string, oldVal, newVal string) {
@@ -232,6 +263,17 @@ func (s *IssueService) Create(ctx context.Context, workspaceSlug string, project
}
_ = s.activity.Create(ctx, row)
}
+ // Description mention notifications (assignment notifications are emitted
+ // by ReplaceAssignees above — not here, to prevent double-fire).
+ if issue.DescriptionHTML != "" {
+ mentioned := text.ParseMentionUserIDs(issue.DescriptionHTML)
+ if len(mentioned) > 0 {
+ s.autoSubscribe(ctx, issue, mentioned)
+ if s.notify != nil {
+ s.notify.IssueMentioned(ctx, issue, userID, mentioned, "description")
+ }
+ }
+ }
return issue, nil
}
@@ -244,10 +286,12 @@ func (s *IssueService) Update(ctx context.Context, workspaceSlug string, project
// Snapshot values before mutation so we can diff them for the activity log.
prevName := issue.Name
prevPriority := issue.Priority
+ prevStateID := issue.StateID
prevState := uuidString(issue.StateID)
prevStart := dateString(issue.StartDate)
prevTarget := dateString(issue.TargetDate)
prevParent := uuidString(issue.ParentID)
+ prevDescription := issue.DescriptionHTML
if name != nil {
issue.Name = *name
@@ -282,21 +326,58 @@ func (s *IssueService) Update(ctx context.Context, workspaceSlug string, project
// (it's noisy and the change history is rebuildable from issue versions).
if name != nil && prevName != issue.Name {
s.recordActivity(ctx, issue, userID, "name", prevName, issue.Name)
+ if s.notify != nil {
+ s.notify.IssueFieldChanged(ctx, issue, userID, "name", prevName, issue.Name)
+ }
}
if priority != nil && prevPriority != issue.Priority {
s.recordActivity(ctx, issue, userID, "priority", prevPriority, issue.Priority)
+ if s.notify != nil {
+ s.notify.IssueFieldChanged(ctx, issue, userID, "priority", prevPriority, issue.Priority)
+ }
}
if stateID != nil && prevState != uuidString(issue.StateID) {
s.recordActivity(ctx, issue, userID, "state", prevState, uuidString(issue.StateID))
+ if s.notify != nil {
+ s.notify.IssueStateChanged(ctx, issue, userID, prevStateID, issue.StateID)
+ }
}
if startDate != nil && prevStart != dateString(issue.StartDate) {
s.recordActivity(ctx, issue, userID, "start_date", prevStart, dateString(issue.StartDate))
+ if s.notify != nil {
+ s.notify.IssueFieldChanged(ctx, issue, userID, "start_date", prevStart, dateString(issue.StartDate))
+ }
}
if targetDate != nil && prevTarget != dateString(issue.TargetDate) {
s.recordActivity(ctx, issue, userID, "target_date", prevTarget, dateString(issue.TargetDate))
+ if s.notify != nil {
+ s.notify.IssueFieldChanged(ctx, issue, userID, "target_date", prevTarget, dateString(issue.TargetDate))
+ }
}
if parentID != nil && prevParent != uuidString(issue.ParentID) {
s.recordActivity(ctx, issue, userID, "parent", prevParent, uuidString(issue.ParentID))
+ if s.notify != nil {
+ s.notify.IssueFieldChanged(ctx, issue, userID, "parent", prevParent, uuidString(issue.ParentID))
+ }
+ }
+
+ // New mentions added in the description: notify only the *newly* added IDs
+ // so editing a description twice doesn't repeatedly ping the same users.
+ if description != nil && prevDescription != issue.DescriptionHTML {
+ prevSet := uuidSet(text.ParseMentionUserIDs(prevDescription))
+ newIDs := text.ParseMentionUserIDs(issue.DescriptionHTML)
+ added := make([]uuid.UUID, 0, len(newIDs))
+ for _, id := range newIDs {
+ if !prevSet[id] {
+ added = append(added, id)
+ }
+ }
+ if len(added) > 0 {
+ s.autoSubscribe(ctx, issue, added)
+ if s.notify != nil {
+ s.notify.IssueMentioned(ctx, issue, userID, added, "description")
+ }
+ }
}
if assigneeIDs != nil {
@@ -362,7 +443,13 @@ func (s *IssueService) Delete(ctx context.Context, workspaceSlug string, project
if err != nil {
return err
}
- return s.is.Delete(ctx, issueID)
+ if err := s.is.Delete(ctx, issueID); err != nil {
+ return err
+ }
+ if s.notify != nil {
+ s.notify.IssueDeleted(ctx, issueID)
+ }
+ return nil
}
func (s *IssueService) ListAssignees(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID) ([]uuid.UUID, error) {
@@ -384,7 +471,14 @@ func (s *IssueService) AddAssignee(ctx context.Context, workspaceSlug string, pr
ProjectID: issue.ProjectID,
WorkspaceID: issue.WorkspaceID,
}
- return s.is.AddAssignee(ctx, a)
+ if err := s.is.AddAssignee(ctx, a); err != nil {
+ return err
+ }
+ s.autoSubscribe(ctx, issue, []uuid.UUID{assigneeID})
+ if s.notify != nil {
+ s.notify.IssueAssigned(ctx, issue, userID, []uuid.UUID{assigneeID})
+ }
+ return nil
}
func (s *IssueService) RemoveAssignee(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID, assigneeID uuid.UUID) error {
@@ -400,6 +494,7 @@ func (s *IssueService) ReplaceAssignees(ctx context.Context, workspaceSlug strin
if err != nil {
return err
}
+ prevAssignees, _ := s.is.ListAssigneesForIssue(ctx, issueID)
if err := s.is.ClearAssigneesForIssue(ctx, issueID); err != nil {
return err
}
@@ -414,6 +509,19 @@ func (s *IssueService) ReplaceAssignees(ctx context.Context, workspaceSlug strin
return err
}
}
+ prevSet := uuidSet(prevAssignees)
+ added := make([]uuid.UUID, 0, len(assigneeIDs))
+ for _, id := range assigneeIDs {
+ if !prevSet[id] {
+ added = append(added, id)
+ }
+ }
+ if len(added) > 0 {
+ s.autoSubscribe(ctx, issue, added)
+ if s.notify != nil {
+ s.notify.IssueAssigned(ctx, issue, userID, added)
+ }
+ }
return nil
}
@@ -439,6 +547,45 @@ func (s *IssueService) ReplaceLabels(ctx context.Context, workspaceSlug string,
return nil
}
+// IsSubscribed reports whether the current user is subscribed to the issue.
+func (s *IssueService) IsSubscribed(ctx context.Context, workspaceSlug string, projectID, issueID, userID uuid.UUID) (bool, error) {
+ if _, err := s.GetByID(ctx, workspaceSlug, projectID, issueID, userID); err != nil {
+ return false, err
+ }
+ if s.subs == nil {
+ return false, nil
+ }
+ return s.subs.IsSubscribed(ctx, issueID, userID)
+}
+
+// Subscribe explicitly subscribes the current user to the issue.
+func (s *IssueService) Subscribe(ctx context.Context, workspaceSlug string, projectID, issueID, userID uuid.UUID) error {
+ issue, err := s.GetByID(ctx, workspaceSlug, projectID, issueID, userID)
+ if err != nil {
+ return err
+ }
+ if s.subs == nil {
+ return nil
+ }
+ return s.subs.Subscribe(ctx, &model.IssueSubscriber{
+ IssueID: issue.ID,
+ SubscriberID: userID,
+ ProjectID: issue.ProjectID,
+ WorkspaceID: issue.WorkspaceID,
+ })
+}
+
+// Unsubscribe removes the current user's subscription to the issue.
+func (s *IssueService) Unsubscribe(ctx context.Context, workspaceSlug string, projectID, issueID, userID uuid.UUID) error {
+ if _, err := s.GetByID(ctx, workspaceSlug, projectID, issueID, userID); err != nil {
+ return err
+ }
+ if s.subs == nil {
+ return nil
+ }
+ return s.subs.Unsubscribe(ctx, issueID, userID)
+}
+
// ListActivities returns the chronological activity log for an issue.
// Returns an empty slice when the activity store isn't wired (defensive).
func (s *IssueService) ListActivities(ctx context.Context, workspaceSlug string, projectID, issueID uuid.UUID, userID uuid.UUID) ([]model.IssueActivity, error) {
diff --git a/api/internal/service/notification.go b/api/internal/service/notification.go
index 61335601..3462e94d 100644
--- a/api/internal/service/notification.go
+++ b/api/internal/service/notification.go
@@ -2,23 +2,72 @@ package service
import (
"context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "strings"
+ "time"
"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/google/uuid"
)
-// NotificationService handles notification business logic.
+// NotificationService handles notification business logic — both serving the
+// inbox to receivers and fanning out new notifications when domain events
+// happen elsewhere in the API.
+//
+// Emit* methods are called by IssueService and CommentService AFTER their
+// own DB writes succeed. emit() returns are logged and swallowed: a transient
+// notifications-table failure must not roll back the user's actual change.
type NotificationService struct {
- ns *store.NotificationStore
- ws *store.WorkspaceStore
+ ns *store.NotificationStore
+ ws *store.WorkspaceStore
+ is *store.IssueStore // for assignee + creator lookups (receiver computation)
+ ps *store.ProjectStore // for project-membership filter
+ us *store.UserStore // for actor display name
+ ss *store.StateStore // for state name resolution in Message payload
+ subs *store.IssueSubscriberStore // optional — subscriber-based receivers
+ prefs *store.UserNotificationPreferenceStore // optional — preference gating
+ log *slog.Logger
}
-func NewNotificationService(ns *store.NotificationStore, ws *store.WorkspaceStore) *NotificationService {
- return &NotificationService{ns: ns, ws: ws}
+func NewNotificationService(
+ ns *store.NotificationStore,
+ ws *store.WorkspaceStore,
+ is *store.IssueStore,
+ ps *store.ProjectStore,
+ us *store.UserStore,
+ ss *store.StateStore,
+) *NotificationService {
+ return &NotificationService{ns: ns, ws: ws, is: is, ps: ps, us: us, ss: ss}
}
-func (s *NotificationService) List(ctx context.Context, workspaceSlug string, userID uuid.UUID, unreadOnly bool) ([]model.Notification, error) {
+// SetSubscriberStore wires per-issue subscriber lookups so subscribers are
+// included in receiver fan-out. Optional.
+func (s *NotificationService) SetSubscriberStore(subs *store.IssueSubscriberStore) {
+ s.subs = subs
+}
+
+// SetPreferenceStore wires user notification preferences so receivers who have
+// opted out of a category are dropped before insert. Optional.
+func (s *NotificationService) SetPreferenceStore(p *store.UserNotificationPreferenceStore) {
+ s.prefs = p
+}
+
+// SetLogger lets the caller wire a request-scoped slog. Optional.
+func (s *NotificationService) SetLogger(l *slog.Logger) { s.log = l }
+
+func (s *NotificationService) logger() *slog.Logger {
+ if s.log != nil {
+ return s.log
+ }
+ return slog.Default()
+}
+
+// ----- Reads --------------------------------------------------------------
+
+func (s *NotificationService) List(ctx context.Context, workspaceSlug string, userID uuid.UUID, opts store.ListOpts) ([]model.Notification, error) {
var workspaceID *uuid.UUID
if workspaceSlug != "" {
wrk, err := s.ws.GetBySlug(ctx, workspaceSlug)
@@ -31,7 +80,23 @@ func (s *NotificationService) List(ctx context.Context, workspaceSlug string, us
}
workspaceID = &wrk.ID
}
- return s.ns.ListByReceiverID(ctx, userID, workspaceID, unreadOnly)
+ return s.ns.ListByReceiverID(ctx, userID, workspaceID, opts)
+}
+
+func (s *NotificationService) UnreadCount(ctx context.Context, workspaceSlug string, userID uuid.UUID) (total, mentions int64, err error) {
+ var workspaceID *uuid.UUID
+ if workspaceSlug != "" {
+ wrk, err := s.ws.GetBySlug(ctx, workspaceSlug)
+ if err != nil {
+ return 0, 0, ErrProjectForbidden
+ }
+ ok, _ := s.ws.IsMember(ctx, wrk.ID, userID)
+ if !ok {
+ return 0, 0, ErrProjectForbidden
+ }
+ workspaceID = &wrk.ID
+ }
+ return s.ns.CountUnread(ctx, userID, workspaceID)
}
func (s *NotificationService) MarkRead(ctx context.Context, notificationID uuid.UUID, userID uuid.UUID) error {
@@ -45,6 +110,67 @@ func (s *NotificationService) MarkRead(ctx context.Context, notificationID uuid.
return s.ns.MarkRead(ctx, notificationID, userID)
}
+func (s *NotificationService) MarkUnread(ctx context.Context, notificationID uuid.UUID, userID uuid.UUID) error {
+ n, err := s.ns.GetByID(ctx, notificationID)
+ if err != nil {
+ return err
+ }
+ if n.ReceiverID != userID {
+ return ErrProjectForbidden
+ }
+ return s.ns.MarkUnread(ctx, notificationID, userID)
+}
+
+func (s *NotificationService) Archive(ctx context.Context, notificationID uuid.UUID, userID uuid.UUID) error {
+ n, err := s.ns.GetByID(ctx, notificationID)
+ if err != nil {
+ return err
+ }
+ if n.ReceiverID != userID {
+ return ErrProjectForbidden
+ }
+ return s.ns.Archive(ctx, notificationID, userID)
+}
+
+func (s *NotificationService) Unarchive(ctx context.Context, notificationID uuid.UUID, userID uuid.UUID) error {
+ n, err := s.ns.GetByID(ctx, notificationID)
+ if err != nil {
+ return err
+ }
+ if n.ReceiverID != userID {
+ return ErrProjectForbidden
+ }
+ return s.ns.Unarchive(ctx, notificationID, userID)
+}
+
+// ErrInvalidSnooze indicates a Snooze call with a non-future timestamp.
+var ErrInvalidSnooze = errors.New("snooze: until must be in the future")
+
+func (s *NotificationService) Snooze(ctx context.Context, notificationID uuid.UUID, userID uuid.UUID, until time.Time) error {
+ if !until.After(time.Now()) {
+ return ErrInvalidSnooze
+ }
+ n, err := s.ns.GetByID(ctx, notificationID)
+ if err != nil {
+ return err
+ }
+ if n.ReceiverID != userID {
+ return ErrProjectForbidden
+ }
+ return s.ns.Snooze(ctx, notificationID, userID, until)
+}
+
+func (s *NotificationService) Unsnooze(ctx context.Context, notificationID uuid.UUID, userID uuid.UUID) error {
+ n, err := s.ns.GetByID(ctx, notificationID)
+ if err != nil {
+ return err
+ }
+ if n.ReceiverID != userID {
+ return ErrProjectForbidden
+ }
+ return s.ns.Unsnooze(ctx, notificationID, userID)
+}
+
func (s *NotificationService) MarkAllRead(ctx context.Context, workspaceSlug string, userID uuid.UUID) error {
var workspaceID *uuid.UUID
if workspaceSlug != "" {
@@ -60,3 +186,458 @@ func (s *NotificationService) MarkAllRead(ctx context.Context, workspaceSlug str
}
return s.ns.MarkAllRead(ctx, userID, workspaceID)
}
+
+// ----- Emit fan-out -------------------------------------------------------
+
+// emitParams carries the per-notification context used to build the row.
+type emitParams struct {
+ issue *model.Issue
+ actorID uuid.UUID
+ sender string
+ field string // optional, for change notifications
+ before, after string // optional, denormalized human-readable values
+ commentPreview string // optional, for `commented`
+ mentionContext string // "description" | "comment", used for sender=mentioned
+ classifyMention func(uuid.UUID) bool // optional, returns true if receiver should get sender=mentioned instead of params.sender
+}
+
+// emit fans `params` out to one row per receiver.
+//
+// - dedupe receivers
+// - exclude the actor (they did the action)
+// - filter through project membership (workspace member who isn't on the
+// project shouldn't get a notification for it)
+// - generate Title + Message JSON server-side and CreateMany
+//
+// Errors are logged and swallowed.
+func (s *NotificationService) emit(ctx context.Context, receivers []uuid.UUID, params emitParams) {
+ if params.issue == nil {
+ return
+ }
+ if len(receivers) == 0 {
+ return
+ }
+ clean := dedupExclude(receivers, params.actorID)
+ if len(clean) == 0 {
+ return
+ }
+
+ // Workspace-membership filter: drop receivers who are no longer members of
+ // the workspace the issue belongs to. We deliberately don't filter by
+ // project_members — Devlane allows assigning a workspace member to an issue
+ // without first adding them to project_members, so requiring that row would
+ // silently drop legitimately-assigned receivers.
+ allowed := make([]uuid.UUID, 0, len(clean))
+ for _, id := range clean {
+ ok, err := s.ws.IsMember(ctx, params.issue.WorkspaceID, id)
+ if err != nil {
+ s.logger().Warn("notification workspace-member check failed", "err", err)
+ continue
+ }
+ if !ok {
+ continue
+ }
+ allowed = append(allowed, id)
+ }
+ if len(allowed) == 0 {
+ return
+ }
+
+ // Preference gating: receivers who have disabled the relevant category for
+ // this `sender` value are dropped. Mention notifications still pass unless
+ // the user has explicitly turned mentions off.
+ if s.prefs != nil {
+ gated := make([]uuid.UUID, 0, len(allowed))
+ for _, id := range allowed {
+ if s.allowedBySender(ctx, id, params.sender, params.classifyMention) {
+ gated = append(gated, id)
+ }
+ }
+ allowed = gated
+ }
+ if len(allowed) == 0 {
+ return
+ }
+
+ // Resolve denormalized fields used to render the title.
+ actorName := s.actorDisplayName(ctx, params.actorID)
+ projectIdent := s.projectIdentifier(ctx, params.issue.ProjectID)
+ issueRef := fmt.Sprintf("%s-%d", projectIdent, params.issue.SequenceID)
+
+ rows := make([]model.Notification, 0, len(allowed))
+ for _, receiverID := range allowed {
+ sender := params.sender
+ if params.classifyMention != nil && params.classifyMention(receiverID) {
+ sender = model.NotificationSenderMentioned
+ }
+ title := buildTitle(sender, actorName, issueRef, params.field, params.before, params.after)
+ msg := buildMessage(messageInputs{
+ actor: actorRef{id: params.actorID, name: actorName},
+ issue: issueRef2{id: params.issue.ID, name: params.issue.Name, seq: params.issue.SequenceID, projectIdentifier: projectIdent},
+ projectID: params.issue.ProjectID,
+ field: params.field,
+ before: params.before,
+ after: params.after,
+ commentPreview: params.commentPreview,
+ contextKind: params.mentionContext,
+ })
+ issueID := params.issue.ID
+ projectID := params.issue.ProjectID
+ actor := params.actorID
+ rows = append(rows, model.Notification{
+ Title: title,
+ Message: msg,
+ Sender: sender,
+ ReceiverID: receiverID,
+ WorkspaceID: params.issue.WorkspaceID,
+ ProjectID: &projectID,
+ TriggeredByID: &actor,
+ EntityIdentifier: &issueID,
+ EntityName: model.NotificationEntityIssue,
+ })
+ }
+ if err := s.ns.CreateMany(ctx, rows); err != nil {
+ s.logger().Warn("notification fan-out failed", "err", err, "issue_id", params.issue.ID, "receivers", len(rows))
+ }
+}
+
+// actorDisplayName returns the user's display name, falling back through
+// first+last name → username → "Someone".
+func (s *NotificationService) actorDisplayName(ctx context.Context, id uuid.UUID) string {
+ if id == uuid.Nil {
+ return "Someone"
+ }
+ u, err := s.us.GetByID(ctx, id)
+ if err != nil || u == nil {
+ return "Someone"
+ }
+ if u.DisplayName != "" {
+ return u.DisplayName
+ }
+ full := strings.TrimSpace(u.FirstName + " " + u.LastName)
+ if full != "" {
+ return full
+ }
+ if u.Username != "" {
+ return u.Username
+ }
+ return "Someone"
+}
+
+func (s *NotificationService) projectIdentifier(ctx context.Context, projectID uuid.UUID) string {
+ p, err := s.ps.GetByID(ctx, projectID)
+ if err != nil || p == nil || p.Identifier == "" {
+ return "ISSUE"
+ }
+ return p.Identifier
+}
+
+// stateName resolves a state UUID to its display name. Returns "" if not found.
+func (s *NotificationService) stateName(ctx context.Context, id *uuid.UUID) string {
+ if id == nil || *id == uuid.Nil {
+ return ""
+ }
+ st, err := s.ss.GetByID(ctx, *id)
+ if err != nil || st == nil {
+ return ""
+ }
+ return st.Name
+}
+
+// computeIssueReceivers returns assignees ∪ creator ∪ subscribers for an issue.
+// Used by Comment / StateChange / FieldChange emitters.
+func (s *NotificationService) computeIssueReceivers(ctx context.Context, issue *model.Issue) []uuid.UUID {
+ out := make([]uuid.UUID, 0, 8)
+ if assignees, err := s.is.ListAssigneesForIssue(ctx, issue.ID); err == nil {
+ out = append(out, assignees...)
+ }
+ if issue.CreatedByID != nil {
+ out = append(out, *issue.CreatedByID)
+ }
+ if s.subs != nil {
+ if ids, err := s.subs.ListByIssue(ctx, issue.ID); err == nil {
+ out = append(out, ids...)
+ }
+ }
+ return out
+}
+
+// ----- Public emitters ----------------------------------------------------
+
+// IssueAssigned notifies the newly-added assignees. Receivers = added IDs only.
+func (s *NotificationService) IssueAssigned(ctx context.Context, issue *model.Issue, actorID uuid.UUID, added []uuid.UUID) {
+ if issue == nil || len(added) == 0 {
+ return
+ }
+ s.emit(ctx, added, emitParams{
+ issue: issue,
+ actorID: actorID,
+ sender: model.NotificationSenderAssigned,
+ })
+}
+
+// IssueMentioned notifies users mentioned in an issue description (or by extension,
+// any rich-text where contextKind specifies the source). Used by IssueService.{Create,Update}
+// for description-mention diffs.
+func (s *NotificationService) IssueMentioned(ctx context.Context, issue *model.Issue, actorID uuid.UUID, mentioned []uuid.UUID, contextKind string) {
+ if issue == nil || len(mentioned) == 0 {
+ return
+ }
+ s.emit(ctx, mentioned, emitParams{
+ issue: issue,
+ actorID: actorID,
+ sender: model.NotificationSenderMentioned,
+ mentionContext: contextKind,
+ })
+}
+
+// IssueCommented notifies the issue's followers (assignees ∪ creator) and any
+// users mentioned inside the comment body. Mentioned receivers get
+// sender=mentioned; everyone else gets sender=commented.
+func (s *NotificationService) IssueCommented(ctx context.Context, issue *model.Issue, actorID uuid.UUID, commentText string, mentioned []uuid.UUID) {
+ if issue == nil {
+ return
+ }
+ receivers := s.computeIssueReceivers(ctx, issue)
+ receivers = append(receivers, mentioned...)
+ mentionSet := make(map[uuid.UUID]bool, len(mentioned))
+ for _, id := range mentioned {
+ mentionSet[id] = true
+ }
+ preview := stripPreview(commentText, 140)
+ s.emit(ctx, receivers, emitParams{
+ issue: issue,
+ actorID: actorID,
+ sender: model.NotificationSenderCommented,
+ commentPreview: preview,
+ mentionContext: "comment",
+ classifyMention: func(id uuid.UUID) bool { return mentionSet[id] },
+ })
+}
+
+// IssueStateChanged notifies followers when state moved.
+func (s *NotificationService) IssueStateChanged(ctx context.Context, issue *model.Issue, actorID uuid.UUID, prevStateID, newStateID *uuid.UUID) {
+ if issue == nil {
+ return
+ }
+ receivers := s.computeIssueReceivers(ctx, issue)
+ s.emit(ctx, receivers, emitParams{
+ issue: issue,
+ actorID: actorID,
+ sender: model.NotificationSenderStateChanged,
+ field: "state",
+ before: s.stateName(ctx, prevStateID),
+ after: s.stateName(ctx, newStateID),
+ })
+}
+
+// IssueFieldChanged is a catch-all for non-state field updates (priority, due dates, parent, name).
+func (s *NotificationService) IssueFieldChanged(ctx context.Context, issue *model.Issue, actorID uuid.UUID, field, before, after string) {
+ if issue == nil || field == "" {
+ return
+ }
+ receivers := s.computeIssueReceivers(ctx, issue)
+ s.emit(ctx, receivers, emitParams{
+ issue: issue,
+ actorID: actorID,
+ sender: model.NotificationSenderSubscribed,
+ field: field,
+ before: before,
+ after: after,
+ })
+}
+
+// IssueDeleted garbage-collects rows pointing at the now-gone issue, so users
+// don't click an inbox row that lands on a 404.
+func (s *NotificationService) IssueDeleted(ctx context.Context, issueID uuid.UUID) {
+ if issueID == uuid.Nil {
+ return
+ }
+ if err := s.ns.DeleteByEntity(ctx, issueID); err != nil {
+ s.logger().Warn("notification cleanup on issue delete failed", "err", err, "issue_id", issueID)
+ }
+}
+
+// ----- Title + Message construction ---------------------------------------
+
+func buildTitle(sender, actor, issueRef, field, before, after string) string {
+ switch sender {
+ case model.NotificationSenderAssigned:
+ return fmt.Sprintf("%s assigned you to %s", actor, issueRef)
+ case model.NotificationSenderMentioned:
+ return fmt.Sprintf("%s mentioned you in %s", actor, issueRef)
+ case model.NotificationSenderCommented:
+ return fmt.Sprintf("%s commented on %s", actor, issueRef)
+ case model.NotificationSenderStateChanged:
+ if before != "" && after != "" {
+ return fmt.Sprintf("%s moved %s from %s to %s", actor, issueRef, before, after)
+ }
+ if after != "" {
+ return fmt.Sprintf("%s set %s state to %s", actor, issueRef, after)
+ }
+ return fmt.Sprintf("%s changed the state of %s", actor, issueRef)
+ case model.NotificationSenderSubscribed:
+ fieldLabel := humanFieldName(field)
+ if before != "" && after != "" {
+ return fmt.Sprintf("%s changed %s of %s from %s to %s", actor, fieldLabel, issueRef, before, after)
+ }
+ if after != "" {
+ return fmt.Sprintf("%s set %s of %s to %s", actor, fieldLabel, issueRef, after)
+ }
+ return fmt.Sprintf("%s updated %s on %s", actor, fieldLabel, issueRef)
+ default:
+ return fmt.Sprintf("%s updated %s", actor, issueRef)
+ }
+}
+
+func humanFieldName(field string) string {
+ switch field {
+ case "start_date":
+ return "start date"
+ case "target_date":
+ return "due date"
+ case "parent":
+ return "parent"
+ case "priority":
+ return "priority"
+ case "name":
+ return "title"
+ default:
+ return field
+ }
+}
+
+type actorRef struct {
+ id uuid.UUID
+ name string
+}
+type issueRef2 struct {
+ id uuid.UUID
+ name string
+ seq int
+ projectIdentifier string
+}
+type messageInputs struct {
+ actor actorRef
+ issue issueRef2
+ projectID uuid.UUID
+ field string
+ before, after string
+ commentPreview string
+ contextKind string
+}
+
+func buildMessage(in messageInputs) model.JSONMap {
+ m := model.JSONMap{
+ "actor": map[string]any{
+ "id": in.actor.id.String(),
+ "display_name": in.actor.name,
+ },
+ "issue": map[string]any{
+ "id": in.issue.id.String(),
+ "name": in.issue.name,
+ "sequence_id": in.issue.seq,
+ "project_identifier": in.issue.projectIdentifier,
+ },
+ }
+ if in.field != "" {
+ m["field"] = in.field
+ }
+ if in.before != "" {
+ m["before"] = in.before
+ }
+ if in.after != "" {
+ m["after"] = in.after
+ }
+ if in.commentPreview != "" {
+ m["comment_preview"] = in.commentPreview
+ }
+ if in.contextKind != "" {
+ m["context"] = in.contextKind
+ }
+ return m
+}
+
+// allowedBySender returns true if the receiver's preferences permit a notification
+// of this sender type. The mention classifier overrides the default sender
+// when the user is mentioned in this row, so a receiver who has comments
+// disabled but mentions enabled still gets the mention.
+func (s *NotificationService) allowedBySender(ctx context.Context, userID uuid.UUID, sender string, classify func(uuid.UUID) bool) bool {
+ if s.prefs == nil {
+ return true
+ }
+ effective := sender
+ if classify != nil && classify(userID) {
+ effective = model.NotificationSenderMentioned
+ }
+ p, err := s.prefs.GetGlobal(ctx, userID)
+ if err != nil || p == nil {
+ // Default: allow everything when no preference row exists.
+ return true
+ }
+ switch effective {
+ case model.NotificationSenderMentioned:
+ return p.Mention
+ case model.NotificationSenderCommented:
+ return p.Comment
+ case model.NotificationSenderStateChanged:
+ return p.StateChange
+ case model.NotificationSenderSubscribed:
+ return p.PropertyChange
+ case model.NotificationSenderAssigned:
+ // Assignment notifications are not separately gated — receiving an
+ // assignment is fundamental to working on an issue. We honor only
+ // PropertyChange here as a coarse opt-out.
+ return p.PropertyChange
+ }
+ return true
+}
+
+// dedupExclude returns receivers minus exclude, with duplicates and uuid.Nil removed.
+// Order is preserved by first occurrence.
+func dedupExclude(receivers []uuid.UUID, exclude uuid.UUID) []uuid.UUID {
+ if len(receivers) == 0 {
+ return nil
+ }
+ seen := make(map[uuid.UUID]struct{}, len(receivers))
+ out := make([]uuid.UUID, 0, len(receivers))
+ for _, id := range receivers {
+ if id == uuid.Nil || id == exclude {
+ continue
+ }
+ if _, dup := seen[id]; dup {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ return out
+}
+
+// stripPreview reduces an HTML/rich-text snippet to plain text and trims to
+// maxLen runes. Truncating by rune (not byte) keeps multi-byte UTF-8 sequences
+// intact; otherwise a slice could cut a code point in half and corrupt the
+// preview stored in the notification payload.
+func stripPreview(htmlContent string, maxLen int) string {
+ if htmlContent == "" {
+ return ""
+ }
+ var b strings.Builder
+ inTag := false
+ for _, r := range htmlContent {
+ switch {
+ case r == '<':
+ inTag = true
+ case r == '>':
+ inTag = false
+ case !inTag:
+ b.WriteRune(r)
+ }
+ }
+ out := strings.Join(strings.Fields(b.String()), " ")
+ runes := []rune(out)
+ if len(runes) > maxLen {
+ out = strings.TrimSpace(string(runes[:maxLen])) + "…"
+ }
+ return out
+}
diff --git a/api/internal/service/notification_test.go b/api/internal/service/notification_test.go
new file mode 100644
index 00000000..4431f7b3
--- /dev/null
+++ b/api/internal/service/notification_test.go
@@ -0,0 +1,218 @@
+package service
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/google/uuid"
+)
+
+func TestDedupExclude(t *testing.T) {
+ a := uuid.MustParse("11111111-1111-1111-1111-111111111111")
+ b := uuid.MustParse("22222222-2222-2222-2222-222222222222")
+ c := uuid.MustParse("33333333-3333-3333-3333-333333333333")
+
+ tests := []struct {
+ name string
+ receivers []uuid.UUID
+ exclude uuid.UUID
+ want []uuid.UUID
+ }{
+ {"empty", nil, a, nil},
+ {"only excluded", []uuid.UUID{a, a}, a, []uuid.UUID{}},
+ {"dedupes", []uuid.UUID{a, b, a, c, b}, uuid.Nil, []uuid.UUID{a, b, c}},
+ {"removes nil", []uuid.UUID{a, uuid.Nil, b}, uuid.Nil, []uuid.UUID{a, b}},
+ {"excludes actor and dedupes", []uuid.UUID{a, b, c, b, a}, b, []uuid.UUID{a, c}},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := dedupExclude(tc.receivers, tc.exclude)
+ if !sameOrderedUUIDs(got, tc.want) {
+ t.Fatalf("dedupExclude(%v, %v) = %v; want %v", tc.receivers, tc.exclude, got, tc.want)
+ }
+ })
+ }
+}
+
+func sameOrderedUUIDs(a, b []uuid.UUID) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ for i := range a {
+ if a[i] != b[i] {
+ return false
+ }
+ }
+ return true
+}
+
+func TestBuildTitle(t *testing.T) {
+ tests := []struct {
+ name string
+ sender string
+ actor string
+ ref string
+ field string
+ before, after string
+ wantContains []string
+ wantNotContain []string
+ }{
+ {
+ name: "assigned",
+ sender: model.NotificationSenderAssigned,
+ actor: "Sarah",
+ ref: "PRJ-42",
+ wantContains: []string{"Sarah", "assigned", "PRJ-42"},
+ },
+ {
+ name: "mentioned",
+ sender: model.NotificationSenderMentioned,
+ actor: "Sarah",
+ ref: "PRJ-42",
+ wantContains: []string{"Sarah", "mentioned", "PRJ-42"},
+ },
+ {
+ name: "commented",
+ sender: model.NotificationSenderCommented,
+ actor: "Sarah",
+ ref: "PRJ-42",
+ wantContains: []string{"Sarah", "commented", "PRJ-42"},
+ },
+ {
+ name: "state with before+after",
+ sender: model.NotificationSenderStateChanged,
+ actor: "Sarah",
+ ref: "PRJ-42",
+ before: "Backlog",
+ after: "In Progress",
+ wantContains: []string{"Sarah", "PRJ-42", "Backlog", "In Progress"},
+ },
+ {
+ name: "state with only after",
+ sender: model.NotificationSenderStateChanged,
+ actor: "Sarah",
+ ref: "PRJ-42",
+ after: "Done",
+ wantContains: []string{"Sarah", "PRJ-42", "Done"},
+ },
+ {
+ name: "field changed (priority)",
+ sender: model.NotificationSenderSubscribed,
+ actor: "Sarah",
+ ref: "PRJ-42",
+ field: "priority",
+ before: "low",
+ after: "high",
+ wantContains: []string{"Sarah", "priority", "PRJ-42", "low", "high"},
+ },
+ {
+ name: "field changed (target_date) maps to friendly label",
+ sender: model.NotificationSenderSubscribed,
+ actor: "Sarah",
+ ref: "PRJ-42",
+ field: "target_date",
+ after: "2026-05-15",
+ wantContains: []string{"due date", "PRJ-42"},
+ wantNotContain: []string{"target_date"},
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := buildTitle(tc.sender, tc.actor, tc.ref, tc.field, tc.before, tc.after)
+ for _, s := range tc.wantContains {
+ if !strings.Contains(got, s) {
+ t.Errorf("title %q missing %q", got, s)
+ }
+ }
+ for _, s := range tc.wantNotContain {
+ if strings.Contains(got, s) {
+ t.Errorf("title %q must not contain %q", got, s)
+ }
+ }
+ })
+ }
+}
+
+func TestBuildMessage(t *testing.T) {
+ actorID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
+ issueID := uuid.MustParse("22222222-2222-2222-2222-222222222222")
+
+ in := messageInputs{
+ actor: actorRef{id: actorID, name: "Sarah"},
+ issue: issueRef2{id: issueID, name: "Fix login form", seq: 42, projectIdentifier: "PRJ"},
+ field: "state",
+ after: "In Progress",
+ }
+ m := buildMessage(in)
+
+ actor, _ := m["actor"].(map[string]any)
+ if actor["display_name"] != "Sarah" {
+ t.Errorf("actor.display_name = %v; want Sarah", actor["display_name"])
+ }
+ if actor["id"] != actorID.String() {
+ t.Errorf("actor.id = %v; want %s", actor["id"], actorID)
+ }
+
+ issue, _ := m["issue"].(map[string]any)
+ if issue["sequence_id"] != 42 {
+ t.Errorf("issue.sequence_id = %v; want 42", issue["sequence_id"])
+ }
+ if issue["project_identifier"] != "PRJ" {
+ t.Errorf("issue.project_identifier = %v; want PRJ", issue["project_identifier"])
+ }
+ if issue["name"] != "Fix login form" {
+ t.Errorf("issue.name = %v; want Fix login form", issue["name"])
+ }
+
+ if m["field"] != "state" {
+ t.Errorf("field = %v; want state", m["field"])
+ }
+ if m["after"] != "In Progress" {
+ t.Errorf("after = %v; want In Progress", m["after"])
+ }
+ // `before` is empty in input — should be omitted, not present as "".
+ if _, ok := m["before"]; ok {
+ t.Errorf("empty before should not be in message map")
+ }
+}
+
+func TestStripPreview(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ max int
+ want string
+ }{
+ {"empty", "", 100, ""},
+ {"plain text", "hello world", 100, "hello world"},
+ {"strips tags", "hello world
", 100, "hello world"},
+ {"collapses whitespace", "hello\n\n world\t!
", 100, "hello world !"},
+ {"truncates with ellipsis", "abcdefghij", 5, "abcde…"},
+ {"strips mention markup", `hi @bob ok
`, 100, "hi @bob ok"},
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := stripPreview(tc.in, tc.max)
+ if got != tc.want {
+ t.Errorf("stripPreview(%q, %d) = %q; want %q", tc.in, tc.max, got, tc.want)
+ }
+ })
+ }
+}
+
+func TestHumanFieldName(t *testing.T) {
+ tests := map[string]string{
+ "start_date": "start date",
+ "target_date": "due date",
+ "parent": "parent",
+ "priority": "priority",
+ "name": "title",
+ "unknown": "unknown",
+ }
+ for in, want := range tests {
+ if got := humanFieldName(in); got != want {
+ t.Errorf("humanFieldName(%q) = %q; want %q", in, got, want)
+ }
+ }
+}
diff --git a/api/internal/service/page.go b/api/internal/service/page.go
index 40ceb398..f157a2f7 100644
--- a/api/internal/service/page.go
+++ b/api/internal/service/page.go
@@ -3,25 +3,99 @@ package service
import (
"context"
"errors"
+ "strings"
"github.com/Devlaner/devlane/api/internal/model"
"github.com/Devlaner/devlane/api/internal/store"
"github.com/google/uuid"
)
-var ErrPageNotFound = errors.New("page not found")
+var (
+ ErrPageNotFound = errors.New("page not found")
+ ErrPageLocked = errors.New("page is locked")
+ ErrPageArchived = errors.New("page is archived")
+ ErrPageReadOnly = errors.New("no permission to edit this page")
+ ErrPageNotArchived = errors.New("page must be archived before deletion")
+ ErrPageBadParent = errors.New("invalid parent page")
+ ErrPageBadRequest = errors.New("invalid page request")
+)
-// PageService handles page business logic.
+// PageService handles page business logic and permission gating.
+//
+// Permission model:
+// - Public page (access=0): any workspace member can view; any workspace member
+// can edit content unless is_locked is true (then only owner). Owner-only meta.
+// - Private page (access=1): owner-only view + edit.
+// - Archived page: read-only for everyone; owner can unarchive or delete.
+//
+// Workspace membership is the auth boundary. Project membership is not strictly
+// required to view a public page in the workspace, since pages can be linked to
+// multiple projects via project_pages.
type PageService struct {
pageStore *store.PageStore
projectStore *store.ProjectStore
ws *store.WorkspaceStore
+ favorites *store.UserFavoriteStore // optional — when nil, favorite endpoints return errors
}
func NewPageService(pageStore *store.PageStore, projectStore *store.ProjectStore, ws *store.WorkspaceStore) *PageService {
return &PageService{pageStore: pageStore, projectStore: projectStore, ws: ws}
}
+// SetFavoriteStore wires the user_favorites store. Optional — without it,
+// favorite endpoints return ErrPageNotFound (treated as 404).
+func (s *PageService) SetFavoriteStore(f *store.UserFavoriteStore) { s.favorites = f }
+
+// ----- Permission helpers --------------------------------------------------
+
+// canView returns true if userID may read the page.
+//
+// isMember must be true if userID is a member of page.WorkspaceID. The caller
+// is responsible for that check; canView avoids re-querying it.
+//
+// Workspace membership is the auth boundary — even page owners lose access
+// when they are removed from the workspace.
+func canView(page *model.Page, userID uuid.UUID, isMember bool) bool {
+ if page == nil || !isMember {
+ return false
+ }
+ if page.OwnedByID == userID {
+ return true
+ }
+ return page.Access == model.PageAccessPublic
+}
+
+// canEditContent returns true if userID may edit page body content right now.
+//
+// The lock blocks everyone except the owner. Archived pages are read-only.
+// Private pages are owner-only.
+func canEditContent(page *model.Page, userID uuid.UUID, isMember bool) bool {
+ if page == nil || !isMember {
+ return false
+ }
+ if page.ArchivedAt != nil {
+ return false
+ }
+ if page.OwnedByID == userID {
+ return true
+ }
+ if page.IsLocked {
+ return false
+ }
+ return page.Access == model.PageAccessPublic
+}
+
+// canEditMeta returns true if userID may change name/access/parent. Owner-only,
+// but the owner must still be a workspace member (auth boundary).
+func canEditMeta(page *model.Page, userID uuid.UUID, isMember bool) bool {
+ if page == nil || !isMember {
+ return false
+ }
+ return page.OwnedByID == userID
+}
+
+// ----- Common access guards -----------------------------------------------
+
func (s *PageService) ensureWorkspaceAccess(ctx context.Context, workspaceSlug string, userID uuid.UUID) (uuid.UUID, error) {
wrk, err := s.ws.GetBySlug(ctx, workspaceSlug)
if err != nil {
@@ -50,22 +124,90 @@ func (s *PageService) ensureProjectAccess(ctx context.Context, workspaceSlug str
return nil
}
+// loadAndCheckView fetches a page and 404s if userID can't view it. Returns
+// the page plus the workspace-membership flag so subsequent permission checks
+// don't re-query.
+func (s *PageService) loadAndCheckView(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) (*model.Page, bool, error) {
+ wrk, err := s.ws.GetBySlug(ctx, workspaceSlug)
+ if err != nil {
+ return nil, false, ErrProjectForbidden
+ }
+ isMember, _ := s.ws.IsMember(ctx, wrk.ID, userID)
+ page, err := s.pageStore.GetByID(ctx, pageID)
+ if err != nil {
+ return nil, false, ErrPageNotFound
+ }
+ if page.WorkspaceID != wrk.ID {
+ return nil, false, ErrPageNotFound
+ }
+ if !canView(page, userID, isMember) {
+ return nil, false, ErrPageNotFound
+ }
+ return page, isMember, nil
+}
+
+// ----- Reads --------------------------------------------------------------
+
// List lists pages for workspace or for a project (projectID optional).
-func (s *PageService) List(ctx context.Context, workspaceSlug string, projectID *uuid.UUID, userID uuid.UUID) ([]model.Page, error) {
+// Filters are honoured on the server (owner, archived, search, parent).
+//
+// Private pages owned by other users are filtered out post-query. The list
+// page is small enough that this is fine; for very large workspaces we'd push
+// the predicate into SQL.
+func (s *PageService) List(ctx context.Context, workspaceSlug string, projectID *uuid.UUID, userID uuid.UUID, opts store.ListPagesOpts) ([]model.Page, error) {
workspaceID, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
if err != nil {
return nil, err
}
+ var pages []model.Page
if projectID != nil {
if err := s.ensureProjectAccess(ctx, workspaceSlug, *projectID, userID); err != nil {
return nil, err
}
- return s.pageStore.ListByProjectID(ctx, *projectID)
+ pages, err = s.pageStore.ListByProjectID(ctx, *projectID, opts)
+ } else {
+ pages, err = s.pageStore.ListByWorkspaceID(ctx, workspaceID, opts)
+ }
+ if err != nil {
+ return nil, err
+ }
+ out := pages[:0]
+ for _, p := range pages {
+ if p.Access == model.PageAccessPrivate && p.OwnedByID != userID {
+ continue
+ }
+ out = append(out, p)
+ }
+ return out, nil
+}
+
+func (s *PageService) ListChildren(ctx context.Context, workspaceSlug string, parentID, userID uuid.UUID) ([]model.Page, error) {
+ parent, _, err := s.loadAndCheckView(ctx, workspaceSlug, parentID, userID)
+ if err != nil {
+ return nil, err
+ }
+ children, err := s.pageStore.ListChildrenByParentID(ctx, parent.ID)
+ if err != nil {
+ return nil, err
+ }
+ out := children[:0]
+ for _, p := range children {
+ if p.Access == model.PageAccessPrivate && p.OwnedByID != userID {
+ continue
+ }
+ out = append(out, p)
}
- return s.pageStore.ListByWorkspaceID(ctx, workspaceID)
+ return out, nil
+}
+
+func (s *PageService) Get(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) (*model.Page, error) {
+ page, _, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
+ return page, err
}
-func (s *PageService) Create(ctx context.Context, workspaceSlug string, projectID *uuid.UUID, userID uuid.UUID, name, descriptionHTML string, access int16) (*model.Page, error) {
+// ----- Writes -------------------------------------------------------------
+
+func (s *PageService) Create(ctx context.Context, workspaceSlug string, projectID *uuid.UUID, userID uuid.UUID, name, html string, access int16, parentID *uuid.UUID) (*model.Page, error) {
workspaceID, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
if err != nil {
return nil, err
@@ -75,76 +217,368 @@ func (s *PageService) Create(ctx context.Context, workspaceSlug string, projectI
return nil, err
}
}
+ if access != model.PageAccessPublic && access != model.PageAccessPrivate {
+ access = model.PageAccessPublic
+ }
+ if html == "" {
+ html = ""
+ }
+ if name == "" {
+ name = "Untitled page"
+ }
+ // Validate parent (if any) before insert. We re-use loadAndCheckView so the
+ // caller must be able to *view* the proposed parent — not just be in the
+ // same workspace. This prevents creating a child under another user's
+ // private page where the parent would be inaccessible to the creator.
+ if parentID != nil {
+ if _, _, err := s.loadAndCheckView(ctx, workspaceSlug, *parentID, userID); err != nil {
+ return nil, ErrPageBadParent
+ }
+ }
page := &model.Page{
Name: name,
- DescriptionHTML: descriptionHTML,
+ DescriptionHTML: html,
OwnedByID: userID,
WorkspaceID: workspaceID,
Access: access,
+ ParentID: parentID,
+ CreatedByID: &userID,
+ UpdatedByID: &userID,
}
- if err := s.pageStore.Create(ctx, page); err != nil {
+ // Insert the page and its project_pages link in a single transaction so a
+ // failed link doesn't leave behind an orphan page that's invisible to the
+ // project's pages list.
+ if err := s.pageStore.CreateWithProjectLink(ctx, page, projectID, &userID); err != nil {
return nil, err
}
- if projectID != nil {
- _ = s.pageStore.AddProjectPage(ctx, &model.ProjectPage{
- ProjectID: *projectID,
- PageID: page.ID,
- WorkspaceID: workspaceID,
- })
- }
+ // Initial version row so history is anchored from page-creation onward.
+ _ = s.pageStore.CreateVersion(ctx, &model.PageVersion{
+ PageID: page.ID,
+ WorkspaceID: workspaceID,
+ OwnedByID: userID,
+ DescriptionHTML: html,
+ DescriptionStripped: stripHTML(html),
+ })
return page, nil
}
-func (s *PageService) Get(ctx context.Context, workspaceSlug string, pageID uuid.UUID, userID uuid.UUID) (*model.Page, error) {
- _, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
+// PageMetaUpdate carries the optional fields UpdateMeta accepts. nil values
+// are left untouched; zero values for required fields are validated.
+//
+// LogoProps is owner-editable like name/access/parent: passing a non-nil value
+// replaces the stored JSON, and SetLogoProps with a nil LogoProps clears it.
+type PageMetaUpdate struct {
+ Name *string
+ Access *int16
+ ParentID *uuid.UUID
+ ClearParent bool
+ LogoProps model.JSONMap
+ SetLogoProps bool
+}
+
+// UpdateMeta changes name / access / parent / logo. Owner-only.
+func (s *PageService) UpdateMeta(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID, in PageMetaUpdate) (*model.Page, error) {
+ page, isMember, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
if err != nil {
return nil, err
}
- page, err := s.pageStore.GetByID(ctx, pageID)
+ if !canEditMeta(page, userID, isMember) {
+ return nil, ErrPageReadOnly
+ }
+ if page.ArchivedAt != nil {
+ return nil, ErrPageArchived
+ }
+ if in.Name != nil {
+ page.Name = strings.TrimSpace(*in.Name)
+ }
+ if in.Access != nil {
+ if *in.Access != model.PageAccessPublic && *in.Access != model.PageAccessPrivate {
+ return nil, ErrPageBadRequest
+ }
+ page.Access = *in.Access
+ }
+ if in.ClearParent {
+ page.ParentID = nil
+ } else if in.ParentID != nil {
+ if err := s.validateParent(ctx, workspaceSlug, page, *in.ParentID, userID); err != nil {
+ return nil, err
+ }
+ page.ParentID = in.ParentID
+ }
+ if in.SetLogoProps {
+ page.LogoProps = in.LogoProps
+ }
+ page.UpdatedByID = &userID
+ if err := s.pageStore.Update(ctx, page); err != nil {
+ return nil, err
+ }
+ return page, nil
+}
+
+// validateParent rejects parents that would corrupt the tree:
+// - same page as itself,
+// - caller cannot view the proposed parent,
+// - parent is a descendant of the page being updated (cycle).
+//
+// Walks up the proposed parent's ancestor chain. Bounded by a max depth so a
+// pre-existing cycle in the data can't loop us forever.
+func (s *PageService) validateParent(ctx context.Context, workspaceSlug string, page *model.Page, parentID, userID uuid.UUID) error {
+ if parentID == page.ID {
+ return ErrPageBadParent
+ }
+ parent, _, err := s.loadAndCheckView(ctx, workspaceSlug, parentID, userID)
if err != nil {
- return nil, ErrPageNotFound
+ return ErrPageBadParent
+ }
+ const maxDepth = 64
+ cursor := parent
+ for i := 0; i < maxDepth && cursor.ParentID != nil; i++ {
+ if *cursor.ParentID == page.ID {
+ return ErrPageBadParent
+ }
+ next, err := s.pageStore.GetByID(ctx, *cursor.ParentID)
+ if err != nil {
+ break
+ }
+ cursor = next
+ }
+ return nil
+}
+
+// UpdateContent autosaves the body HTML. Records a version row on every save.
+func (s *PageService) UpdateContent(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID, html string) (*model.Page, error) {
+ page, isMember, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
+ if err != nil {
+ return nil, err
}
+ if !canEditContent(page, userID, isMember) {
+ switch {
+ case page.ArchivedAt != nil:
+ return nil, ErrPageArchived
+ case page.IsLocked:
+ return nil, ErrPageLocked
+ default:
+ return nil, ErrPageReadOnly
+ }
+ }
+ if err := s.pageStore.UpdateContent(ctx, page.ID, html, userID); err != nil {
+ return nil, err
+ }
+ page.DescriptionHTML = html
+ page.UpdatedByID = &userID
+ _ = s.pageStore.CreateVersion(ctx, &model.PageVersion{
+ PageID: page.ID,
+ WorkspaceID: page.WorkspaceID,
+ OwnedByID: userID,
+ DescriptionHTML: html,
+ DescriptionStripped: stripHTML(html),
+ })
return page, nil
}
-func (s *PageService) Update(ctx context.Context, workspaceSlug string, pageID uuid.UUID, userID uuid.UUID, name, descriptionHTML string, access *int16) (*model.Page, error) {
- _, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
+func (s *PageService) Lock(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) error {
+ page, isMember, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
+ if err != nil {
+ return err
+ }
+ if !canEditMeta(page, userID, isMember) {
+ return ErrPageReadOnly
+ }
+ return s.pageStore.Lock(ctx, page.ID)
+}
+
+func (s *PageService) Unlock(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) error {
+ page, isMember, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
+ if err != nil {
+ return err
+ }
+ if !canEditMeta(page, userID, isMember) {
+ return ErrPageReadOnly
+ }
+ return s.pageStore.Unlock(ctx, page.ID)
+}
+
+func (s *PageService) Archive(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) error {
+ page, isMember, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
+ if err != nil {
+ return err
+ }
+ if !canEditMeta(page, userID, isMember) {
+ return ErrPageReadOnly
+ }
+ // Archive the root and its descendants atomically so a failure doesn't
+ // leave a partially-archived subtree behind.
+ if err := s.pageStore.ArchiveTree(ctx, page.ID); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (s *PageService) Unarchive(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) error {
+ page, isMember, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
+ if err != nil {
+ return err
+ }
+ if !canEditMeta(page, userID, isMember) {
+ return ErrPageReadOnly
+ }
+ return s.pageStore.Unarchive(ctx, page.ID)
+}
+
+func (s *PageService) Delete(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) error {
+ page, isMember, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
+ if err != nil {
+ return err
+ }
+ if !canEditMeta(page, userID, isMember) {
+ return ErrPageReadOnly
+ }
+ if page.ArchivedAt == nil {
+ return ErrPageNotArchived
+ }
+ return s.pageStore.Delete(ctx, page.ID)
+}
+
+// Duplicate copies a page (and its project_pages links) into a new page owned
+// by the caller. Any workspace member who can view the source may duplicate it.
+func (s *PageService) Duplicate(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) (*model.Page, error) {
+ src, _, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
if err != nil {
return nil, err
}
- page, err := s.pageStore.GetByID(ctx, pageID)
+ dup := &model.Page{
+ Name: strings.TrimSpace(src.Name) + " (Copy)",
+ DescriptionHTML: src.DescriptionHTML,
+ OwnedByID: userID,
+ WorkspaceID: src.WorkspaceID,
+ Access: src.Access,
+ Color: src.Color,
+ ParentID: src.ParentID,
+ CreatedByID: &userID,
+ UpdatedByID: &userID,
+ }
+ projectIDs, err := s.pageStore.ListProjectIDsForPage(ctx, src.ID)
if err != nil {
- return nil, ErrPageNotFound
+ return nil, err
+ }
+ // Create the duplicate page + every project_pages link atomically.
+ if err := s.pageStore.DuplicateInTransaction(ctx, dup, projectIDs, &userID); err != nil {
+ return nil, err
}
- if page.OwnedByID != userID {
+ _ = s.pageStore.CreateVersion(ctx, &model.PageVersion{
+ PageID: dup.ID,
+ WorkspaceID: dup.WorkspaceID,
+ OwnedByID: userID,
+ DescriptionHTML: dup.DescriptionHTML,
+ DescriptionStripped: stripHTML(dup.DescriptionHTML),
+ })
+ return dup, nil
+}
+
+// ----- Versions -----------------------------------------------------------
+
+func (s *PageService) ListVersions(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) ([]model.PageVersion, error) {
+ page, _, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
+ if err != nil {
+ return nil, err
+ }
+ return s.pageStore.ListVersions(ctx, page.ID)
+}
+
+func (s *PageService) GetVersion(ctx context.Context, workspaceSlug string, pageID, versionID, userID uuid.UUID) (*model.PageVersion, error) {
+ page, _, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
+ if err != nil {
+ return nil, err
+ }
+ v, err := s.pageStore.GetVersion(ctx, versionID)
+ if err != nil || v.PageID != page.ID {
return nil, ErrPageNotFound
}
- if name != "" {
- page.Name = name
+ return v, nil
+}
+
+// RestoreVersion sets the page's body to the version's HTML and records a new
+// version row so the restore itself is browsable history.
+func (s *PageService) RestoreVersion(ctx context.Context, workspaceSlug string, pageID, versionID, userID uuid.UUID) (*model.Page, error) {
+ page, isMember, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
+ if err != nil {
+ return nil, err
}
- if descriptionHTML != "" {
- page.DescriptionHTML = descriptionHTML
+ if !canEditContent(page, userID, isMember) {
+ return nil, ErrPageReadOnly
}
- if access != nil {
- page.Access = *access
+ v, err := s.pageStore.GetVersion(ctx, versionID)
+ if err != nil || v.PageID != page.ID {
+ return nil, ErrPageNotFound
}
- if err := s.pageStore.Update(ctx, page); err != nil {
+ if err := s.pageStore.UpdateContent(ctx, page.ID, v.DescriptionHTML, userID); err != nil {
return nil, err
}
+ page.DescriptionHTML = v.DescriptionHTML
+ page.UpdatedByID = &userID
+ _ = s.pageStore.CreateVersion(ctx, &model.PageVersion{
+ PageID: page.ID,
+ WorkspaceID: page.WorkspaceID,
+ OwnedByID: userID,
+ DescriptionHTML: v.DescriptionHTML,
+ DescriptionStripped: stripHTML(v.DescriptionHTML),
+ })
return page, nil
}
-func (s *PageService) Delete(ctx context.Context, workspaceSlug string, pageID uuid.UUID, userID uuid.UUID) error {
- _, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
+// ----- Favorites ----------------------------------------------------------
+
+func (s *PageService) AddFavorite(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) error {
+ page, _, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID)
if err != nil {
return err
}
- page, err := s.pageStore.GetByID(ctx, pageID)
- if err != nil {
+ if s.favorites == nil {
return ErrPageNotFound
}
- if page.OwnedByID != userID {
+ return s.favorites.AddPage(ctx, userID, page.WorkspaceID, nil, page.ID)
+}
+
+func (s *PageService) RemoveFavorite(ctx context.Context, workspaceSlug string, pageID, userID uuid.UUID) error {
+ if _, _, err := s.loadAndCheckView(ctx, workspaceSlug, pageID, userID); err != nil {
+ return err
+ }
+ if s.favorites == nil {
return ErrPageNotFound
}
- return s.pageStore.Delete(ctx, pageID)
+ return s.favorites.RemovePage(ctx, userID, pageID)
+}
+
+func (s *PageService) ListFavoriteIDs(ctx context.Context, workspaceSlug string, userID uuid.UUID) ([]uuid.UUID, error) {
+ workspaceID, err := s.ensureWorkspaceAccess(ctx, workspaceSlug, userID)
+ if err != nil {
+ return nil, err
+ }
+ if s.favorites == nil {
+ return []uuid.UUID{}, nil
+ }
+ return s.favorites.ListPageIDsByUserAndWorkspace(ctx, userID, workspaceID)
+}
+
+// ----- Helpers ------------------------------------------------------------
+
+// stripHTML returns a plain-text approximation of an HTML body for search/preview.
+// Cheap heuristic: drop tags + collapse whitespace.
+func stripHTML(htmlContent string) string {
+ if htmlContent == "" {
+ return ""
+ }
+ var b strings.Builder
+ inTag := false
+ for _, r := range htmlContent {
+ switch {
+ case r == '<':
+ inTag = true
+ case r == '>':
+ inTag = false
+ case !inTag:
+ b.WriteRune(r)
+ }
+ }
+ return strings.Join(strings.Fields(b.String()), " ")
}
diff --git a/api/internal/service/page_test.go b/api/internal/service/page_test.go
new file mode 100644
index 00000000..58606358
--- /dev/null
+++ b/api/internal/service/page_test.go
@@ -0,0 +1,79 @@
+package service
+
+import (
+ "testing"
+ "time"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/google/uuid"
+)
+
+func TestPagePermissions(t *testing.T) {
+ owner := uuid.MustParse("11111111-1111-1111-1111-111111111111")
+ other := uuid.MustParse("22222222-2222-2222-2222-222222222222")
+ now := time.Now()
+
+ publicPage := &model.Page{ID: uuid.New(), OwnedByID: owner, Access: model.PageAccessPublic}
+ privatePage := &model.Page{ID: uuid.New(), OwnedByID: owner, Access: model.PageAccessPrivate}
+ lockedPublic := &model.Page{ID: uuid.New(), OwnedByID: owner, Access: model.PageAccessPublic, IsLocked: true}
+ archivedPublic := &model.Page{ID: uuid.New(), OwnedByID: owner, Access: model.PageAccessPublic, ArchivedAt: &now}
+
+ tests := []struct {
+ name string
+ page *model.Page
+ userID uuid.UUID
+ isMember bool
+ wantView bool
+ wantContent bool
+ wantMeta bool
+ }{
+ {"owner-public", publicPage, owner, true, true, true, true},
+ {"owner-private", privatePage, owner, true, true, true, true},
+ {"owner-locked-public", lockedPublic, owner, true, true, true, true},
+ {"owner-archived", archivedPublic, owner, true, true, false, true},
+
+ // Owner who is no longer a workspace member loses all access — the
+ // auth boundary is workspace membership, not ownership.
+ {"owner-no-longer-member", publicPage, owner, false, false, false, false},
+
+ {"member-public", publicPage, other, true, true, true, false},
+ {"member-private", privatePage, other, true, false, false, false},
+ {"member-locked-public", lockedPublic, other, true, true, false, false},
+ {"member-archived", archivedPublic, other, true, true, false, false},
+
+ {"non-member-public", publicPage, other, false, false, false, false},
+ {"non-member-private", privatePage, other, false, false, false, false},
+ {"non-member-locked", lockedPublic, other, false, false, false, false},
+
+ {"nil page", nil, owner, true, false, false, false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := canView(tc.page, tc.userID, tc.isMember); got != tc.wantView {
+ t.Errorf("canView = %v; want %v", got, tc.wantView)
+ }
+ if got := canEditContent(tc.page, tc.userID, tc.isMember); got != tc.wantContent {
+ t.Errorf("canEditContent = %v; want %v", got, tc.wantContent)
+ }
+ if got := canEditMeta(tc.page, tc.userID, tc.isMember); got != tc.wantMeta {
+ t.Errorf("canEditMeta = %v; want %v", got, tc.wantMeta)
+ }
+ })
+ }
+}
+
+func TestStripHTML(t *testing.T) {
+ tests := map[string]string{
+ "": "",
+ "hello world
": "hello world",
+ "hi there
": "hi there",
+ "line1
\nline2
": "line1 line2",
+ `tag with attr @bob
`: "tag with attr @bob",
+ }
+ for in, want := range tests {
+ if got := stripHTML(in); got != want {
+ t.Errorf("stripHTML(%q) = %q; want %q", in, got, want)
+ }
+ }
+}
diff --git a/api/internal/store/issue_subscriber.go b/api/internal/store/issue_subscriber.go
new file mode 100644
index 00000000..d4120eed
--- /dev/null
+++ b/api/internal/store/issue_subscriber.go
@@ -0,0 +1,60 @@
+package store
+
+import (
+ "context"
+ "errors"
+
+ "github.com/Devlaner/devlane/api/internal/model"
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// IssueSubscriberStore handles issue_subscribers persistence.
+type IssueSubscriberStore struct{ db *gorm.DB }
+
+func NewIssueSubscriberStore(db *gorm.DB) *IssueSubscriberStore {
+ return &IssueSubscriberStore{db: db}
+}
+
+// Subscribe is idempotent — re-subscribing an already-subscribed user is a
+// no-op. The DB enforces UNIQUE(issue_id, subscriber_id); ON CONFLICT DO NOTHING
+// keeps us from raising on duplicates.
+func (s *IssueSubscriberStore) Subscribe(ctx context.Context, sub *model.IssueSubscriber) error {
+ if sub == nil || sub.IssueID == uuid.Nil || sub.SubscriberID == uuid.Nil {
+ return errors.New("subscribe: missing issue or subscriber")
+ }
+ return s.db.WithContext(ctx).
+ Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "issue_id"}, {Name: "subscriber_id"}},
+ DoNothing: true,
+ }).
+ Create(sub).Error
+}
+
+// Unsubscribe hard-deletes the subscriber row. The table has no soft-delete
+// column, so this is a true DELETE.
+func (s *IssueSubscriberStore) Unsubscribe(ctx context.Context, issueID, subscriberID uuid.UUID) error {
+ return s.db.WithContext(ctx).
+ Where("issue_id = ? AND subscriber_id = ?", issueID, subscriberID).
+ Delete(&model.IssueSubscriber{}).Error
+}
+
+// ListByIssue returns the subscriber user IDs for an issue.
+func (s *IssueSubscriberStore) ListByIssue(ctx context.Context, issueID uuid.UUID) ([]uuid.UUID, error) {
+ var ids []uuid.UUID
+ err := s.db.WithContext(ctx).
+ Model(&model.IssueSubscriber{}).
+ Where("issue_id = ?", issueID).
+ Pluck("subscriber_id", &ids).Error
+ return ids, err
+}
+
+func (s *IssueSubscriberStore) IsSubscribed(ctx context.Context, issueID, subscriberID uuid.UUID) (bool, error) {
+ var n int64
+ err := s.db.WithContext(ctx).
+ Model(&model.IssueSubscriber{}).
+ Where("issue_id = ? AND subscriber_id = ?", issueID, subscriberID).
+ Count(&n).Error
+ return n > 0, err
+}
diff --git a/api/internal/store/notification.go b/api/internal/store/notification.go
index 82320356..d12f2b93 100644
--- a/api/internal/store/notification.go
+++ b/api/internal/store/notification.go
@@ -14,15 +14,62 @@ type NotificationStore struct{ db *gorm.DB }
func NewNotificationStore(db *gorm.DB) *NotificationStore { return &NotificationStore{db: db} }
-func (s *NotificationStore) ListByReceiverID(ctx context.Context, receiverID uuid.UUID, workspaceID *uuid.UUID, unreadOnly bool) ([]model.Notification, error) {
+// ListOpts controls notification list filtering.
+//
+// Archived: nil means hide archived (default Inbox view); &false same as nil; &true shows
+// only archived rows (the dedicated Archived tab).
+//
+// IncludeArchived overrides Archived — when true, both archived and active rows are returned.
+//
+// IncludeSnoozed, when false (default), hides rows whose snoozed_till is still in
+// the future. The Archived tab passes true so users can manage snoozed-then-archived
+// items there.
+type ListOpts struct {
+ UnreadOnly bool
+ MentionsOnly bool
+ Archived *bool
+ IncludeArchived bool
+ IncludeSnoozed bool
+}
+
+// Create inserts a single notification.
+func (s *NotificationStore) Create(ctx context.Context, n *model.Notification) error {
+ return s.db.WithContext(ctx).Create(n).Error
+}
+
+// CreateMany inserts a slice of notifications in one statement. No-op on empty input.
+func (s *NotificationStore) CreateMany(ctx context.Context, ns []model.Notification) error {
+ if len(ns) == 0 {
+ return nil
+ }
+ return s.db.WithContext(ctx).Create(&ns).Error
+}
+
+func (s *NotificationStore) ListByReceiverID(ctx context.Context, receiverID uuid.UUID, workspaceID *uuid.UUID, opts ListOpts) ([]model.Notification, error) {
var list []model.Notification
q := s.db.WithContext(ctx).Where("receiver_id = ?", receiverID)
if workspaceID != nil {
q = q.Where("workspace_id = ?", *workspaceID)
}
- if unreadOnly {
+ if opts.UnreadOnly {
q = q.Where("read_at IS NULL")
}
+ if opts.MentionsOnly {
+ q = q.Where("sender = ?", model.NotificationSenderMentioned)
+ }
+ if !opts.IncludeArchived {
+ switch {
+ case opts.Archived == nil:
+ q = q.Where("archived_at IS NULL")
+ case *opts.Archived:
+ q = q.Where("archived_at IS NOT NULL")
+ default:
+ q = q.Where("archived_at IS NULL")
+ }
+ }
+ if !opts.IncludeSnoozed {
+ q = q.Where("(snoozed_till IS NULL OR snoozed_till <= ?)", time.Now())
+ }
err := q.Order("created_at DESC").Limit(100).Find(&list).Error
return list, err
}
@@ -43,11 +90,103 @@ func (s *NotificationStore) MarkRead(ctx context.Context, id uuid.UUID, receiver
Update("read_at", now).Error
}
+func (s *NotificationStore) MarkUnread(ctx context.Context, id uuid.UUID, receiverID uuid.UUID) error {
+ return s.db.WithContext(ctx).Model(&model.Notification{}).
+ Where("id = ? AND receiver_id = ?", id, receiverID).
+ Update("read_at", nil).Error
+}
+
+// MarkAllRead marks every unread notification in the receiver's active inbox
+// as read. "Active" mirrors List/CountUnread: not archived, not still-snoozed.
+// Otherwise the user clears notifications they never saw, and snoozed rows
+// would re-emerge already-read after the snooze expires.
func (s *NotificationStore) MarkAllRead(ctx context.Context, receiverID uuid.UUID, workspaceID *uuid.UUID) error {
now := time.Now()
- q := s.db.WithContext(ctx).Model(&model.Notification{}).Where("receiver_id = ? AND read_at IS NULL", receiverID)
+ q := s.db.WithContext(ctx).Model(&model.Notification{}).
+ Where("receiver_id = ? AND read_at IS NULL AND archived_at IS NULL AND (snoozed_till IS NULL OR snoozed_till <= ?)",
+ receiverID, now)
if workspaceID != nil {
q = q.Where("workspace_id = ?", *workspaceID)
}
return q.Update("read_at", now).Error
}
+
+// CountUnread returns (total, mentions) — both counts cover the active inbox
+// (not archived, not still-snoozed).
+//
+// Postgres path uses a single query with FILTER. Other dialects (sqlite in
+// tests) fall back to two queries — FILTER is Postgres-specific.
+func (s *NotificationStore) CountUnread(ctx context.Context, receiverID uuid.UUID, workspaceID *uuid.UUID) (total, mentions int64, err error) {
+ now := time.Now()
+ if s.db.Dialector.Name() == "postgres" {
+ type row struct {
+ Total int64
+ Mentions int64
+ }
+ var r row
+ q := s.db.WithContext(ctx).
+ Table("notifications").
+ Select("COUNT(*) AS total, COUNT(*) FILTER (WHERE sender = ?) AS mentions", model.NotificationSenderMentioned).
+ Where("receiver_id = ? AND read_at IS NULL AND archived_at IS NULL AND (snoozed_till IS NULL OR snoozed_till <= ?)",
+ receiverID, now)
+ if workspaceID != nil {
+ q = q.Where("workspace_id = ?", *workspaceID)
+ }
+ if err = q.Scan(&r).Error; err != nil {
+ return 0, 0, err
+ }
+ return r.Total, r.Mentions, nil
+ }
+ // Portable fallback: two COUNT(*) queries.
+ base := s.db.WithContext(ctx).Model(&model.Notification{}).
+ Where("receiver_id = ? AND read_at IS NULL AND archived_at IS NULL AND (snoozed_till IS NULL OR snoozed_till <= ?)",
+ receiverID, now)
+ if workspaceID != nil {
+ base = base.Where("workspace_id = ?", *workspaceID)
+ }
+ if err = base.Count(&total).Error; err != nil {
+ return 0, 0, err
+ }
+ if err = base.Where("sender = ?", model.NotificationSenderMentioned).Count(&mentions).Error; err != nil {
+ return 0, 0, err
+ }
+ return total, mentions, nil
+}
+
+// Snooze hides a notification from the active inbox until `until`.
+func (s *NotificationStore) Snooze(ctx context.Context, id, receiverID uuid.UUID, until time.Time) error {
+ return s.db.WithContext(ctx).Model(&model.Notification{}).
+ Where("id = ? AND receiver_id = ?", id, receiverID).
+ Update("snoozed_till", until).Error
+}
+
+// Unsnooze clears the snoozed_till timestamp.
+func (s *NotificationStore) Unsnooze(ctx context.Context, id, receiverID uuid.UUID) error {
+ return s.db.WithContext(ctx).Model(&model.Notification{}).
+ Where("id = ? AND receiver_id = ?", id, receiverID).
+ Update("snoozed_till", nil).Error
+}
+
+// Archive flags a notification as archived for the receiver.
+func (s *NotificationStore) Archive(ctx context.Context, id, receiverID uuid.UUID) error {
+ now := time.Now()
+ return s.db.WithContext(ctx).Model(&model.Notification{}).
+ Where("id = ? AND receiver_id = ?", id, receiverID).
+ Update("archived_at", now).Error
+}
+
+// Unarchive clears archived_at.
+func (s *NotificationStore) Unarchive(ctx context.Context, id, receiverID uuid.UUID) error {
+ return s.db.WithContext(ctx).Model(&model.Notification{}).
+ Where("id = ? AND receiver_id = ?", id, receiverID).
+ Update("archived_at", nil).Error
+}
+
+// DeleteByEntity hard-deletes every notification referencing entityID.
+// Used to garbage-collect rows when the underlying issue is deleted, so users
+// don't click an Inbox row that lands on a 404.
+func (s *NotificationStore) DeleteByEntity(ctx context.Context, entityID uuid.UUID) error {
+ return s.db.WithContext(ctx).
+ Where("entity_identifier = ?", entityID).
+ Delete(&model.Notification{}).Error
+}
diff --git a/api/internal/store/page.go b/api/internal/store/page.go
index 240d28ff..10dbaae4 100644
--- a/api/internal/store/page.go
+++ b/api/internal/store/page.go
@@ -2,21 +2,129 @@ package store
import (
"context"
+ "strings"
+ "time"
"github.com/Devlaner/devlane/api/internal/model"
"github.com/google/uuid"
"gorm.io/gorm"
)
-// PageStore handles page and project_page persistence.
+// PageStore handles page, project_page, and page_versions persistence.
type PageStore struct{ db *gorm.DB }
func NewPageStore(db *gorm.DB) *PageStore { return &PageStore{db: db} }
+// ListPagesOpts controls page list filtering. All fields are optional.
+//
+// Archived: nil hides archived (default Inbox view); &true returns only archived rows.
+// ParentID: nil returns root pages only; &id returns children of that id; pass &uuid.Nil
+// to mean "no parent filter — return everything".
+type ListPagesOpts struct {
+ Archived *bool
+ ParentID *uuid.UUID
+ OwnerID *uuid.UUID
+ UpdatedByID *uuid.UUID
+ Search string
+ OnlyRoots bool
+}
+
func (s *PageStore) Create(ctx context.Context, p *model.Page) error {
return s.db.WithContext(ctx).Create(p).Error
}
+// CreateWithProjectLink inserts a page and, when projectID is non-nil, the
+// matching project_pages link in a single transaction. This avoids leaving an
+// orphan workspace page when the link insert fails. The page argument is
+// updated in place with the generated ID.
+func (s *PageStore) CreateWithProjectLink(ctx context.Context, p *model.Page, projectID *uuid.UUID, createdByID *uuid.UUID) error {
+ return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(p).Error; err != nil {
+ return err
+ }
+ if projectID != nil {
+ link := &model.ProjectPage{
+ ProjectID: *projectID,
+ PageID: p.ID,
+ WorkspaceID: p.WorkspaceID,
+ CreatedByID: createdByID,
+ }
+ if err := tx.Create(link).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+// DuplicateInTransaction copies a page along with its project_pages links in
+// one transaction. Returns the new page (updated in place).
+func (s *PageStore) DuplicateInTransaction(ctx context.Context, dup *model.Page, projectIDs []uuid.UUID, createdByID *uuid.UUID) error {
+ return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := tx.Create(dup).Error; err != nil {
+ return err
+ }
+ for _, pid := range projectIDs {
+ link := &model.ProjectPage{
+ ProjectID: pid,
+ PageID: dup.ID,
+ WorkspaceID: dup.WorkspaceID,
+ CreatedByID: createdByID,
+ }
+ if err := tx.Create(link).Error; err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+// ArchiveTree archives a page and all its descendants in one transaction so
+// failures don't leave a partially-archived subtree behind.
+func (s *PageStore) ArchiveTree(ctx context.Context, rootID uuid.UUID) error {
+ return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ now := time.Now()
+ if err := tx.Model(&model.Page{}).
+ Where("id = ?", rootID).Update("archived_at", now).Error; err != nil {
+ return err
+ }
+ if tx.Dialector.Name() == "postgres" {
+ return tx.Exec(`
+ WITH RECURSIVE descendants AS (
+ SELECT id FROM pages WHERE parent_id = ? AND deleted_at IS NULL
+ UNION ALL
+ SELECT p.id FROM pages p
+ JOIN descendants d ON p.parent_id = d.id
+ WHERE p.deleted_at IS NULL
+ )
+ UPDATE pages SET archived_at = NOW(), updated_at = NOW()
+ WHERE id IN (SELECT id FROM descendants) AND archived_at IS NULL
+ `, rootID).Error
+ }
+ // Iterative fallback for non-Postgres (sqlite tests).
+ queue := []uuid.UUID{rootID}
+ for len(queue) > 0 {
+ next := queue[0]
+ queue = queue[1:]
+ var children []model.Page
+ if err := tx.
+ Where("parent_id = ? AND deleted_at IS NULL", next).Find(&children).Error; err != nil {
+ return err
+ }
+ for _, c := range children {
+ if c.ArchivedAt == nil {
+ if err := tx.Model(&model.Page{}).
+ Where("id = ?", c.ID).Update("archived_at", now).Error; err != nil {
+ return err
+ }
+ }
+ queue = append(queue, c.ID)
+ }
+ }
+ return nil
+ })
+}
+
func (s *PageStore) GetByID(ctx context.Context, id uuid.UUID) (*model.Page, error) {
var page model.Page
err := s.db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&page).Error
@@ -26,22 +134,45 @@ func (s *PageStore) GetByID(ctx context.Context, id uuid.UUID) (*model.Page, err
return &page, nil
}
-func (s *PageStore) ListByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]model.Page, error) {
+// ListByWorkspaceID returns workspace-scoped pages with optional filters.
+func (s *PageStore) ListByWorkspaceID(ctx context.Context, workspaceID uuid.UUID, opts ListPagesOpts) ([]model.Page, error) {
+ q := s.db.WithContext(ctx).Where("workspace_id = ? AND deleted_at IS NULL", workspaceID)
+ q = applyPageFilters(q, opts)
var list []model.Page
- err := s.db.WithContext(ctx).Where("workspace_id = ? AND deleted_at IS NULL", workspaceID).
- Order("sort_order ASC, created_at ASC").Find(&list).Error
+ err := q.Order("sort_order ASC, created_at ASC").Find(&list).Error
return list, err
}
-func (s *PageStore) ListByProjectID(ctx context.Context, projectID uuid.UUID) ([]model.Page, error) {
- var list []model.Page
- err := s.db.WithContext(ctx).Model(&model.Page{}).
+// ListByProjectID returns pages linked to the given project, with optional filters.
+func (s *PageStore) ListByProjectID(ctx context.Context, projectID uuid.UUID, opts ListPagesOpts) ([]model.Page, error) {
+ q := s.db.WithContext(ctx).Model(&model.Page{}).
Joins("INNER JOIN project_pages ON project_pages.page_id = pages.id AND project_pages.deleted_at IS NULL").
- Where("project_pages.project_id = ? AND pages.deleted_at IS NULL", projectID).
- Order("pages.sort_order ASC, pages.created_at ASC").Find(&list).Error
+ Where("project_pages.project_id = ? AND pages.deleted_at IS NULL", projectID)
+ q = applyPageFilters(q, opts)
+ var list []model.Page
+ err := q.Order("pages.sort_order ASC, pages.created_at ASC").Find(&list).Error
+ return list, err
+}
+
+// ListChildrenByParentID returns the immediate children of a page.
+func (s *PageStore) ListChildrenByParentID(ctx context.Context, parentID uuid.UUID) ([]model.Page, error) {
+ var list []model.Page
+ err := s.db.WithContext(ctx).
+ Where("parent_id = ? AND deleted_at IS NULL", parentID).
+ Order("sort_order ASC, created_at ASC").
+ Find(&list).Error
return list, err
}
+// ListProjectIDsForPage returns the project IDs a page is linked to.
+func (s *PageStore) ListProjectIDsForPage(ctx context.Context, pageID uuid.UUID) ([]uuid.UUID, error) {
+ var ids []uuid.UUID
+ err := s.db.WithContext(ctx).Model(&model.ProjectPage{}).
+ Where("page_id = ? AND deleted_at IS NULL", pageID).
+ Pluck("project_id", &ids).Error
+ return ids, err
+}
+
func (s *PageStore) Update(ctx context.Context, p *model.Page) error {
return s.db.WithContext(ctx).Save(p).Error
}
@@ -50,6 +181,89 @@ func (s *PageStore) Delete(ctx context.Context, id uuid.UUID) error {
return s.db.WithContext(ctx).Where("id = ?", id).Delete(&model.Page{}).Error
}
+func (s *PageStore) Lock(ctx context.Context, id uuid.UUID) error {
+ return s.db.WithContext(ctx).Model(&model.Page{}).
+ Where("id = ?", id).Update("is_locked", true).Error
+}
+
+func (s *PageStore) Unlock(ctx context.Context, id uuid.UUID) error {
+ return s.db.WithContext(ctx).Model(&model.Page{}).
+ Where("id = ?", id).Update("is_locked", false).Error
+}
+
+func (s *PageStore) Archive(ctx context.Context, id uuid.UUID) error {
+ return s.db.WithContext(ctx).Model(&model.Page{}).
+ Where("id = ?", id).Update("archived_at", time.Now()).Error
+}
+
+func (s *PageStore) Unarchive(ctx context.Context, id uuid.UUID) error {
+ return s.db.WithContext(ctx).Model(&model.Page{}).
+ Where("id = ?", id).Update("archived_at", nil).Error
+}
+
+// ArchiveDescendants archives all descendants of rootID via a recursive CTE.
+// Postgres-specific. Returns nil on non-Postgres dialects (test setup uses sqlite).
+func (s *PageStore) ArchiveDescendants(ctx context.Context, rootID uuid.UUID) error {
+ if s.db.Dialector.Name() != "postgres" {
+ // Sqlite (used by tests) doesn't support recursive CTE updates the same way.
+ // Walk the tree iteratively as a fallback.
+ return s.archiveDescendantsIterative(ctx, rootID)
+ }
+ return s.db.WithContext(ctx).Exec(`
+ WITH RECURSIVE descendants AS (
+ SELECT id FROM pages WHERE parent_id = ? AND deleted_at IS NULL
+ UNION ALL
+ SELECT p.id FROM pages p
+ JOIN descendants d ON p.parent_id = d.id
+ WHERE p.deleted_at IS NULL
+ )
+ UPDATE pages SET archived_at = NOW(), updated_at = NOW()
+ WHERE id IN (SELECT id FROM descendants) AND archived_at IS NULL
+ `, rootID).Error
+}
+
+func (s *PageStore) archiveDescendantsIterative(ctx context.Context, rootID uuid.UUID) error {
+ now := time.Now()
+ queue := []uuid.UUID{rootID}
+ for len(queue) > 0 {
+ next := queue[0]
+ queue = queue[1:]
+ var children []model.Page
+ if err := s.db.WithContext(ctx).
+ Where("parent_id = ? AND deleted_at IS NULL", next).Find(&children).Error; err != nil {
+ return err
+ }
+ for _, c := range children {
+ if c.ArchivedAt == nil {
+ if err := s.db.WithContext(ctx).Model(&model.Page{}).
+ Where("id = ?", c.ID).Update("archived_at", now).Error; err != nil {
+ return err
+ }
+ }
+ queue = append(queue, c.ID)
+ }
+ }
+ return nil
+}
+
+// SetAccess sets a page's access level (0 public, 1 private). Caller enforces ownership.
+func (s *PageStore) SetAccess(ctx context.Context, id uuid.UUID, access int16) error {
+ return s.db.WithContext(ctx).Model(&model.Page{}).
+ Where("id = ?", id).Update("access", access).Error
+}
+
+// UpdateContent updates a page's HTML body and stamps updated_by_id/updated_at.
+// Caller is responsible for enforcing edit permissions and recording a version row.
+func (s *PageStore) UpdateContent(ctx context.Context, id uuid.UUID, html string, updatedByID uuid.UUID) error {
+ return s.db.WithContext(ctx).Model(&model.Page{}).
+ Where("id = ?", id).
+ Updates(map[string]any{
+ "description_html": html,
+ "updated_by_id": updatedByID,
+ "updated_at": time.Now(),
+ }).Error
+}
+
func (s *PageStore) AddProjectPage(ctx context.Context, pp *model.ProjectPage) error {
return s.db.WithContext(ctx).Create(pp).Error
}
@@ -58,3 +272,55 @@ func (s *PageStore) RemoveProjectPage(ctx context.Context, projectID, pageID uui
return s.db.WithContext(ctx).Where("project_id = ? AND page_id = ?", projectID, pageID).
Delete(&model.ProjectPage{}).Error
}
+
+// ----- Page versions -----
+
+func (s *PageStore) CreateVersion(ctx context.Context, v *model.PageVersion) error {
+ return s.db.WithContext(ctx).Create(v).Error
+}
+
+func (s *PageStore) ListVersions(ctx context.Context, pageID uuid.UUID) ([]model.PageVersion, error) {
+ var list []model.PageVersion
+ err := s.db.WithContext(ctx).
+ Where("page_id = ?", pageID).
+ Order("last_saved_at DESC").
+ Find(&list).Error
+ return list, err
+}
+
+func (s *PageStore) GetVersion(ctx context.Context, versionID uuid.UUID) (*model.PageVersion, error) {
+ var v model.PageVersion
+ err := s.db.WithContext(ctx).Where("id = ?", versionID).First(&v).Error
+ if err != nil {
+ return nil, err
+ }
+ return &v, nil
+}
+
+// ----- Helpers -----
+
+func applyPageFilters(q *gorm.DB, opts ListPagesOpts) *gorm.DB {
+ switch {
+ case opts.Archived == nil:
+ q = q.Where("pages.archived_at IS NULL")
+ case *opts.Archived:
+ q = q.Where("pages.archived_at IS NOT NULL")
+ default:
+ q = q.Where("pages.archived_at IS NULL")
+ }
+ if opts.OnlyRoots {
+ q = q.Where("pages.parent_id IS NULL")
+ } else if opts.ParentID != nil && *opts.ParentID != uuid.Nil {
+ q = q.Where("pages.parent_id = ?", *opts.ParentID)
+ }
+ if opts.OwnerID != nil {
+ q = q.Where("pages.owned_by_id = ?", *opts.OwnerID)
+ }
+ if opts.UpdatedByID != nil {
+ q = q.Where("pages.updated_by_id = ?", *opts.UpdatedByID)
+ }
+ if s := strings.TrimSpace(opts.Search); s != "" {
+ q = q.Where("LOWER(pages.name) LIKE ?", "%"+strings.ToLower(s)+"%")
+ }
+ return q
+}
diff --git a/api/internal/store/user_favorite.go b/api/internal/store/user_favorite.go
index 537e8ce8..194f6d82 100644
--- a/api/internal/store/user_favorite.go
+++ b/api/internal/store/user_favorite.go
@@ -14,6 +14,9 @@ const FavoriteEntityTypeProject = "project"
// FavoriteEntityTypeIssueView is stored in user_favorites.entity_type for saved issue views.
const FavoriteEntityTypeIssueView = "issue_view"
+// FavoriteEntityTypePage is stored in user_favorites.entity_type for project pages.
+const FavoriteEntityTypePage = "page"
+
// UserFavoriteStore handles user_favorites persistence.
type UserFavoriteStore struct{ db *gorm.DB }
@@ -96,3 +99,45 @@ func (s *UserFavoriteStore) IsIssueViewFavorited(ctx context.Context, userID, vi
Count(&count).Error
return count > 0, err
}
+
+// ListPageIDsByUserAndWorkspace returns page IDs the user has favorited in a workspace.
+func (s *UserFavoriteStore) ListPageIDsByUserAndWorkspace(ctx context.Context, userID, workspaceID uuid.UUID) ([]uuid.UUID, error) {
+ var ids []uuid.UUID
+ err := s.db.WithContext(ctx).Model(&model.UserFavorite{}).
+ Where("user_id = ? AND entity_type = ? AND workspace_id = ?", userID, FavoriteEntityTypePage, workspaceID).
+ Pluck("entity_identifier", &ids).Error
+ return ids, err
+}
+
+// AddPage favorites a page for the user. Idempotent on conflict.
+func (s *UserFavoriteStore) AddPage(ctx context.Context, userID, workspaceID uuid.UUID, projectID *uuid.UUID, pageID uuid.UUID) error {
+ fav := &model.UserFavorite{
+ Name: "page",
+ Type: "page",
+ EntityType: FavoriteEntityTypePage,
+ EntityIdentifier: pageID,
+ WorkspaceID: workspaceID,
+ ProjectID: projectID,
+ UserID: userID,
+ }
+ return s.db.WithContext(ctx).Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "user_id"}, {Name: "entity_type"}, {Name: "entity_identifier"}},
+ DoNothing: true,
+ }).Create(fav).Error
+}
+
+// RemovePage removes a page from the user's favorites.
+func (s *UserFavoriteStore) RemovePage(ctx context.Context, userID, pageID uuid.UUID) error {
+ return s.db.WithContext(ctx).
+ Where("user_id = ? AND entity_type = ? AND entity_identifier = ?", userID, FavoriteEntityTypePage, pageID).
+ Delete(&model.UserFavorite{}).Error
+}
+
+// IsPageFavorited reports whether the user has favorited the given page.
+func (s *UserFavoriteStore) IsPageFavorited(ctx context.Context, userID, pageID uuid.UUID) (bool, error) {
+ var count int64
+ err := s.db.WithContext(ctx).Model(&model.UserFavorite{}).
+ Where("user_id = ? AND entity_type = ? AND entity_identifier = ?", userID, FavoriteEntityTypePage, pageID).
+ Count(&count).Error
+ return count > 0, err
+}
diff --git a/api/internal/text/mentions.go b/api/internal/text/mentions.go
new file mode 100644
index 00000000..e97679d0
--- /dev/null
+++ b/api/internal/text/mentions.go
@@ -0,0 +1,65 @@
+// Package text contains helpers for parsing rich-text content stored as HTML
+// in issue descriptions and comments.
+package text
+
+import (
+ "strings"
+
+ "github.com/google/uuid"
+ "golang.org/x/net/html"
+)
+
+// ParseMentionUserIDs extracts the deduplicated set of user IDs referenced by
+// the editor's mention extension. Mentions serialize as
+// @Label.
+//
+// IDs that fail uuid.Parse are dropped silently. Returns nil for empty/invalid input.
+func ParseMentionUserIDs(htmlContent string) []uuid.UUID {
+ htmlContent = strings.TrimSpace(htmlContent)
+ if htmlContent == "" {
+ return nil
+ }
+ z := html.NewTokenizer(strings.NewReader(htmlContent))
+ seen := make(map[uuid.UUID]struct{})
+ out := make([]uuid.UUID, 0, 4)
+ for {
+ tt := z.Next()
+ switch tt {
+ case html.ErrorToken:
+ return out
+ case html.StartTagToken, html.SelfClosingTagToken:
+ name, hasAttr := z.TagName()
+ if string(name) != "span" || !hasAttr {
+ continue
+ }
+ isMention := false
+ var dataID string
+ for {
+ attrName, attrVal, more := z.TagAttr()
+ switch string(attrName) {
+ case "data-type":
+ if string(attrVal) == "mention" {
+ isMention = true
+ }
+ case "data-id":
+ dataID = string(attrVal)
+ }
+ if !more {
+ break
+ }
+ }
+ if !isMention || dataID == "" {
+ continue
+ }
+ id, err := uuid.Parse(dataID)
+ if err != nil {
+ continue
+ }
+ if _, dup := seen[id]; dup {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ }
+}
diff --git a/api/internal/text/mentions_test.go b/api/internal/text/mentions_test.go
new file mode 100644
index 00000000..68ebf711
--- /dev/null
+++ b/api/internal/text/mentions_test.go
@@ -0,0 +1,74 @@
+package text
+
+import (
+ "testing"
+
+ "github.com/google/uuid"
+)
+
+func TestParseMentionUserIDs(t *testing.T) {
+ alice := uuid.MustParse("11111111-1111-1111-1111-111111111111")
+ bob := uuid.MustParse("22222222-2222-2222-2222-222222222222")
+
+ tests := []struct {
+ name string
+ in string
+ want []uuid.UUID
+ }{
+ {
+ name: "empty",
+ in: "",
+ want: nil,
+ },
+ {
+ name: "plain text without mentions",
+ in: "just a comment with @username typed as plain text
",
+ want: []uuid.UUID{},
+ },
+ {
+ name: "single tiptap mention",
+ in: `hey @Alice please review
`,
+ want: []uuid.UUID{alice},
+ },
+ {
+ name: "two mentions deduped",
+ in: `@Alice
+ @Bob
+ @Alice
`,
+ want: []uuid.UUID{alice, bob},
+ },
+ {
+ name: "non-mention spans ignored",
+ in: `@fake not a real mention
`,
+ want: []uuid.UUID{},
+ },
+ {
+ name: "malformed UUID dropped",
+ in: `@x
`,
+ want: []uuid.UUID{},
+ },
+ {
+ name: "mention nested in formatting",
+ in: `@Bob
`,
+ want: []uuid.UUID{bob},
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ got := ParseMentionUserIDs(tc.in)
+ if len(got) != len(tc.want) {
+ t.Fatalf("len mismatch: got %v, want %v", got, tc.want)
+ }
+ gotSet := make(map[uuid.UUID]bool, len(got))
+ for _, id := range got {
+ gotSet[id] = true
+ }
+ for _, id := range tc.want {
+ if !gotSet[id] {
+ t.Errorf("missing %s; got %v", id, got)
+ }
+ }
+ })
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 4037fa05..d7bdf22d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "devlane",
- "version": "1.1.0",
+ "version": "1.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "devlane",
- "version": "1.1.0",
+ "version": "1.2.1",
"license": "ISC",
"devDependencies": {
"@commitlint/cli": "^19.8.1",
diff --git a/package.json b/package.json
index 1f15bb2c..b540689d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "devlane",
- "version": "1.1.0",
+ "version": "1.2.1",
"private": true,
"description": "",
"main": "index.js",
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 321380d7..b7e10026 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -1,22 +1,31 @@
{
"name": "Devlane UI",
- "version": "0.6.0",
+ "version": "0.7.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "Devlane UI",
- "version": "0.6.0",
+ "version": "0.7.3",
"dependencies": {
"@headlessui/react": "^2.2.9",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.22.3",
+ "@tiptap/extension-color": "3.22.3",
+ "@tiptap/extension-highlight": "3.22.3",
+ "@tiptap/extension-image": "3.22.3",
"@tiptap/extension-link": "3.22.3",
"@tiptap/extension-list": "3.22.3",
"@tiptap/extension-mention": "^3.22.3",
"@tiptap/extension-placeholder": "3.22.3",
+ "@tiptap/extension-table": "3.22.3",
+ "@tiptap/extension-table-cell": "3.22.3",
+ "@tiptap/extension-table-header": "3.22.3",
+ "@tiptap/extension-table-row": "3.22.3",
"@tiptap/extension-task-item": "3.22.3",
"@tiptap/extension-task-list": "3.22.3",
+ "@tiptap/extension-text-align": "3.22.3",
+ "@tiptap/extension-text-style": "3.22.3",
"@tiptap/extension-underline": "3.22.3",
"@tiptap/pm": "3.22.3",
"@tiptap/react": "3.22.3",
@@ -2184,6 +2193,19 @@
"@tiptap/pm": "^3.22.3"
}
},
+ "node_modules/@tiptap/extension-color": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-3.22.3.tgz",
+ "integrity": "sha512-eZtOc4Zp5jnlZh4+gvr3JmOa3kPlIU8IbTByDMgXEMdVGZ5cOk+H4TMIlhU0j7j1TMWm8+r1WYtrU9E9ooz9hQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-text-style": "^3.22.3"
+ }
+ },
"node_modules/@tiptap/extension-document": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.3.tgz",
@@ -2265,6 +2287,19 @@
"@tiptap/core": "^3.22.3"
}
},
+ "node_modules/@tiptap/extension-highlight": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-3.22.3.tgz",
+ "integrity": "sha512-iGDzQ3IuVQpfQcWsMEQ0B8q3R83bZZH6l6O2MuCmWbzm/p7mMi5vQwRCMLAbM9xFELq8KjDMHOWeER4fozp/Sg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.22.3"
+ }
+ },
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.3.tgz",
@@ -2279,6 +2314,19 @@
"@tiptap/pm": "^3.22.3"
}
},
+ "node_modules/@tiptap/extension-image": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-3.22.3.tgz",
+ "integrity": "sha512-Qpp8c5LOQaNpHrzjqZtoxtIR+8sSqJ7k8v+8anmYw3nxjvt2kpfT28Vd7aWMX55ZS43LaxMx+MkZqbmgUmMP0w==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.22.3"
+ }
+ },
"node_modules/@tiptap/extension-italic": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.3.tgz",
@@ -2416,6 +2464,59 @@
"@tiptap/core": "^3.22.3"
}
},
+ "node_modules/@tiptap/extension-table": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.22.3.tgz",
+ "integrity": "sha512-inbQSusJad7H0T++L1APg/anfL5d15cNGp2YG3vwo6TQr71nn2c9pepvmz3xuAIt8eygZDRba+4GT/COP1f9QA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.22.3",
+ "@tiptap/pm": "^3.22.3"
+ }
+ },
+ "node_modules/@tiptap/extension-table-cell": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-3.22.3.tgz",
+ "integrity": "sha512-LgaUgNwjHe6J7dq66N7iflC9efjgygsYWkHJtfLzaSU82tXzk8HSwBrMRCaj0PU+AR967/jGixEZIxH9xfXswQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-table": "^3.22.3"
+ }
+ },
+ "node_modules/@tiptap/extension-table-header": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-3.22.3.tgz",
+ "integrity": "sha512-5wQ5rne9ccdbzeqC1AEDaUzlWjnpQNgBctgSPKEv+Da88lRRcGX21H+b/8jhL7fr4/sznr45toKGUg8NmRhboQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-table": "^3.22.3"
+ }
+ },
+ "node_modules/@tiptap/extension-table-row": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-3.22.3.tgz",
+ "integrity": "sha512-dkoHnbOZgb2iQLXsp2FUu0KejAh3LOqDLMixYYU/0lnUwhqGG8kgu6+0YkCiUhedbfMFqVvemdKnRddpTQ4sqg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-table": "^3.22.3"
+ }
+ },
"node_modules/@tiptap/extension-task-item": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-3.22.3.tgz",
@@ -2455,6 +2556,32 @@
"@tiptap/core": "^3.22.3"
}
},
+ "node_modules/@tiptap/extension-text-align": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.22.3.tgz",
+ "integrity": "sha512-dG1NHE0yGf7fYiOdabCJuecI2IJ1uogyY/QvZqvPNaxRjZDoXYuGlMtz9jEDiIdQSaPED2MSsS7KkuNFQIEMGg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.22.3"
+ }
+ },
+ "node_modules/@tiptap/extension-text-style": {
+ "version": "3.22.3",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.22.3.tgz",
+ "integrity": "sha512-JKmWAogM/LX9ZJmXJQalpcR77wWVtVXdRFgvHGsFomW9WFhZqcnIEDWR2sbpZHWtu8dml6eBQGhdLppJmxeFfA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "^3.22.3"
+ }
+ },
"node_modules/@tiptap/extension-underline": {
"version": "3.22.3",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.3.tgz",
diff --git a/ui/package.json b/ui/package.json
index 6158f637..5bc2318b 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -1,7 +1,7 @@
{
"name": "Devlane UI",
"private": true,
- "version": "0.6.0",
+ "version": "0.7.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -17,12 +17,21 @@
"@headlessui/react": "^2.2.9",
"@tailwindcss/vite": "^4.1.18",
"@tiptap/core": "3.22.3",
+ "@tiptap/extension-color": "3.22.3",
+ "@tiptap/extension-highlight": "3.22.3",
+ "@tiptap/extension-image": "3.22.3",
"@tiptap/extension-link": "3.22.3",
"@tiptap/extension-list": "3.22.3",
"@tiptap/extension-mention": "^3.22.3",
"@tiptap/extension-placeholder": "3.22.3",
+ "@tiptap/extension-table": "3.22.3",
+ "@tiptap/extension-table-cell": "3.22.3",
+ "@tiptap/extension-table-header": "3.22.3",
+ "@tiptap/extension-table-row": "3.22.3",
"@tiptap/extension-task-item": "3.22.3",
"@tiptap/extension-task-list": "3.22.3",
+ "@tiptap/extension-text-align": "3.22.3",
+ "@tiptap/extension-text-style": "3.22.3",
"@tiptap/extension-underline": "3.22.3",
"@tiptap/pm": "3.22.3",
"@tiptap/react": "3.22.3",
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 2f823cff..0a725407 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -601,7 +601,6 @@ export interface IssueViewApiResponse {
display_properties?: Record;
access?: number | 'public' | 'private';
sort_order?: number;
- anchor?: string | null;
is_favorite?: boolean;
owned_by?: string;
owned_by_id: string;
@@ -621,34 +620,94 @@ export interface PageApiResponse {
owned_by_id: string;
updated_by_id?: string | null;
workspace_id: string;
+ /** 0 public, 1 private */
access: number;
+ color?: string;
parent_id?: string | null;
sort_order?: number;
+ is_locked: boolean;
archived_at?: string | null;
created_at: string;
updated_at: string;
+ /** Optional JSON blobs we surface as-is. */
+ view_props?: Record;
+ logo_props?: Record;
}
export interface CreatePageRequest {
name: string;
description_html?: string;
project_id?: string | null;
+ parent_id?: string | null;
/** 0 public, 1 private */
access?: number;
}
export interface UpdatePageRequest {
name?: string;
- description_html?: string;
/** 0 public, 1 private */
access?: number;
+ parent_id?: string | null;
+ clear_parent?: boolean;
+ /** Emoji or icon used as the page's logo. Pass `null` to clear. */
+ logo_props?: Record | null;
+}
+
+export interface UpdatePageContentRequest {
+ description_html: string;
+}
+
+/** A snapshot recorded each time a page's body is saved. */
+export interface PageVersionApiResponse {
+ id: string;
+ page_id: string;
+ workspace_id: string;
+ owned_by_id: string;
+ last_saved_at: string;
+ description_html?: string;
+ description_stripped?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+/**
+ * Reason a notification was created. Server-set; drives how the inbox row renders.
+ */
+export type NotificationSender =
+ | 'assigned'
+ | 'mentioned'
+ | 'commented'
+ | 'state_changed'
+ | 'subscribed';
+
+/** Structured payload the API attaches to every notification — denormalised so
+ * the inbox can render N rows without N round-trips. Field set varies by sender. */
+export interface NotificationMessage {
+ actor: { id: string; display_name: string };
+ issue: {
+ id: string;
+ name: string;
+ sequence_id: number;
+ project_identifier: string;
+ };
+ /** Field that changed for state_changed / subscribed senders. */
+ field?: string;
+ /** Human-readable previous value (e.g. state name "Backlog"). */
+ before?: string;
+ /** Human-readable new value. */
+ after?: string;
+ /** First ~140 chars of plain-text comment, present on commented/mentioned-in-comment. */
+ comment_preview?: string;
+ /** Where a mention came from when sender is 'mentioned' — "description" | "comment". */
+ context?: string;
}
/** Notification as returned by the API */
export interface NotificationApiResponse {
id: string;
title: string;
- message?: Record;
+ message?: NotificationMessage;
+ sender?: NotificationSender;
receiver_id: string;
workspace_id: string;
project_id?: string | null;
@@ -656,10 +715,18 @@ export interface NotificationApiResponse {
entity_identifier?: string | null;
entity_name?: string;
read_at?: string | null;
+ archived_at?: string | null;
+ snoozed_till?: string | null;
created_at: string;
updated_at: string;
}
+/** Unread counts surfaced by the bell badge and inbox tabs. */
+export interface UnreadCountResponse {
+ total: number;
+ mentions: number;
+}
+
/** Issue comment as returned by the API */
export interface IssueCommentApiResponse {
id: string;
diff --git a/ui/src/components/PageDescriptionEditor.tsx b/ui/src/components/PageDescriptionEditor.tsx
deleted file mode 100644
index df11d97f..00000000
--- a/ui/src/components/PageDescriptionEditor.tsx
+++ /dev/null
@@ -1,171 +0,0 @@
-import { forwardRef, useEffect, useImperativeHandle } from 'react';
-import { EditorContent, useEditor } from '@tiptap/react';
-import StarterKit from '@tiptap/starter-kit';
-import Placeholder from '@tiptap/extension-placeholder';
-import Underline from '@tiptap/extension-underline';
-import { cn } from '../lib/utils';
-
-export type PageDescriptionEditorHandle = {
- getHtml: () => string;
- isEmpty: () => boolean;
- focus: () => void;
- setHtml: (html: string) => void;
-};
-
-export type PageDescriptionEditorProps = {
- initialHtml?: string;
- placeholder?: string;
- autoFocus?: boolean;
- readOnly?: boolean;
- className?: string;
- /**
- * Optional keyboard shortcut handler.
- * If provided, pressing `Ctrl/Cmd + S` triggers it (default: prevent browser save dialog).
- */
- onSaveShortcut?: () => void;
-};
-
-export const PageDescriptionEditor = forwardRef<
- PageDescriptionEditorHandle,
- PageDescriptionEditorProps
->(
- (
- {
- initialHtml,
- placeholder,
- autoFocus,
- readOnly,
- className,
- onSaveShortcut,
- }: PageDescriptionEditorProps,
- ref,
- ) => {
- const editor = useEditor({
- extensions: [
- StarterKit.configure({
- bulletList: { keepMarks: true, keepAttributes: true },
- orderedList: { keepMarks: true, keepAttributes: true },
- codeBlock: {},
- }),
- Underline,
- Placeholder.configure({
- placeholder: placeholder ?? 'Write something…',
- }),
- ],
- content: initialHtml ?? '',
- editable: !readOnly,
- autofocus: autoFocus ? 'end' : false,
- });
-
- useEffect(() => {
- if (!editor) return;
- // keep editor content in sync when parent loads data
- if (initialHtml !== undefined) {
- editor.commands.setContent(initialHtml || '');
- }
- }, [editor, initialHtml]);
-
- useImperativeHandle(
- ref,
- () => ({
- getHtml: () => editor?.getHTML() ?? '',
- isEmpty: () => {
- const html = (editor?.getHTML() ?? '').trim();
- return html === '' || html === '';
- },
- focus: () => editor?.commands.focus(),
- setHtml: (html: string) => editor?.commands.setContent(html ?? ''),
- }),
- [editor],
- );
-
- if (!editor) return null;
-
- const buttonBase =
- 'inline-flex h-8 w-8 items-center justify-center rounded border border-transparent text-(--txt-icon-tertiary) hover:bg-(--bg-layer-1-hover) hover:text-(--txt-icon-secondary) disabled:opacity-40';
-
- return (
-
-
-
-
-
-
-
-
- {onSaveShortcut && (
-
- Ctrl/Cmd + S to save
-
- )}
-
-
- {
- if (!onSaveShortcut) return;
- const key = event.key?.toLowerCase?.() ?? '';
- if ((event.metaKey || event.ctrlKey) && key === 's') {
- event.preventDefault();
- onSaveShortcut();
- }
- }}
- />
-
-
- );
- },
-);
-
-PageDescriptionEditor.displayName = 'PageDescriptionEditor';
diff --git a/ui/src/components/layout/AppShell.tsx b/ui/src/components/layout/AppShell.tsx
index 5044135b..5094f747 100644
--- a/ui/src/components/layout/AppShell.tsx
+++ b/ui/src/components/layout/AppShell.tsx
@@ -1,5 +1,6 @@
import { Outlet, useLocation } from 'react-router-dom';
import { ModulesFilterProvider } from '../../contexts/ModulesFilterContext';
+import { PageDetailHeaderProvider } from '../../contexts/PageDetailHeaderContext';
import { ProjectSavedViewDisplayProvider } from '../../contexts/ProjectSavedViewDisplayContext';
import { WorkspaceViewsStateProvider } from '../../contexts/WorkspaceViewsStateContext';
import { PageHeader } from './PageHeader';
@@ -11,28 +12,40 @@ export function AppShell() {
const isCyclesPage = pathname.endsWith('/cycles');
const isModulesRoute = pathname.includes('/modules');
const isDraftsRoute = pathname.includes('/drafts');
+ // Pages list and detail pages render their own padding/chrome so the tabs
+ // row sits flush against the PageHeader (Plane parity — header bottom-border
+ // doubles as the tabs-row top-border).
+ const isPagesRoute = pathname.includes('/pages');
return (
-
-
+
diff --git a/ui/src/components/layout/Header.tsx b/ui/src/components/layout/Header.tsx
index 81ed797e..9ffda2a2 100644
--- a/ui/src/components/layout/Header.tsx
+++ b/ui/src/components/layout/Header.tsx
@@ -4,16 +4,20 @@ import { Button } from '../ui';
import { useAuth } from '../../contexts/AuthContext';
import { workspaceService } from '../../services/workspaceService';
import { projectService } from '../../services/projectService';
-import type { WorkspaceApiResponse, ProjectApiResponse } from '../../api/types';
+import { pageService } from '../../services/pageService';
+import { NotificationBell } from '../notifications/NotificationBell';
+import type { WorkspaceApiResponse, ProjectApiResponse, PageApiResponse } from '../../api/types';
export function Header() {
const { user, logout } = useAuth();
- const { workspaceSlug, projectId } = useParams<{
+ const { workspaceSlug, projectId, pageId } = useParams<{
workspaceSlug?: string;
projectId?: string;
+ pageId?: string;
}>();
const [workspace, setWorkspace] = useState
(null);
const [project, setProject] = useState(null);
+ const [page, setPage] = useState(null);
useEffect(() => {
if (!workspaceSlug) {
@@ -21,6 +25,7 @@ export function Header() {
// eslint-disable-next-line react-hooks/set-state-in-effect
setWorkspace(null);
setProject(null);
+ setPage(null);
return;
}
let cancelled = false;
@@ -44,10 +49,22 @@ export function Header() {
} else {
setProject(null);
}
+ if (pageId) {
+ pageService
+ .get(workspaceSlug, pageId)
+ .then((p) => {
+ if (!cancelled) setPage(p);
+ })
+ .catch(() => {
+ if (!cancelled) setPage(null);
+ });
+ } else {
+ setPage(null);
+ }
return () => {
cancelled = true;
};
- }, [workspaceSlug, projectId]);
+ }, [workspaceSlug, projectId, pageId]);
const breadcrumbs: { label: string; href?: string }[] = [];
if (workspace) {
@@ -57,6 +74,15 @@ export function Header() {
label: project.name,
href: `/${workspace.slug}/projects/${project.id}/issues`,
});
+ if (pageId) {
+ breadcrumbs.push({
+ label: 'Pages',
+ href: `/${workspace.slug}/projects/${project.id}/pages`,
+ });
+ if (page) {
+ breadcrumbs.push({ label: page.name || 'Untitled' });
+ }
+ }
}
}
@@ -92,6 +118,7 @@ export function Header() {
+ {workspace ?
: null}
{user?.name}