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

\n

line2

": "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": "![Devlane](./ui/public/devlane-1-dark.png)", "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} + {projectDropdownOpen && ( +
+
+ + + + setProjectSearch(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none" + /> +
+
+ {filteredProjects.map((p) => ( + + ))} +
+
+ )} +
+ + > Modules - - / + + >
- + ); } if (section === 'views') { @@ -3166,22 +3179,65 @@ function ProjectSavedViewDetailHeader({ }) { void _issueCount; const navigate = useNavigate(); - const { filters: workspaceViewFilters } = useWorkspaceViewsState(); + const { filters: workspaceViewFilters, setFilters: setWorkspaceViewFilters } = + useWorkspaceViewsState(); const baseUrl = `/${workspaceSlug}/projects/${projectId}`; const issuesUrl = `${baseUrl}/issues`; const [viewTitle, setViewTitle] = useState('…'); + // Snapshot of the view's persisted filters in WorkspaceViewFilters shape. + // Used for dirty detection ("Save filters" button) and reset. + const [savedFilters, setSavedFilters] = useState(null); + const [savingFilters, setSavingFilters] = useState(false); const [projectDropdownOpen, setProjectDropdownOpen] = useState(false); const [projectSearch, setProjectSearch] = useState(''); const [projects, setProjects] = useState([]); const [filtersDropdownOpen, setFiltersDropdownOpen] = useState(null); const projectDropdownRef = useRef(null); + // Pulls the view from the API and seeds title + savedFilters snapshot. + // The view's `filters` JSON is a flat `Record` matching the + // search-params shape used by parseWorkspaceViewFiltersFromSearchParams. + const refreshView = useRef<() => Promise>(async () => {}); + refreshView.current = async () => { + try { + const v = await viewService.get(workspaceSlug, viewId); + setViewTitle(v?.name?.trim() ? v.name : 'View'); + const raw = v?.filters; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const params = new URLSearchParams(); + for (const [k, val] of Object.entries(raw as Record)) { + if (val == null) continue; + const s = String(val).trim(); + if (s) params.set(k, s); + } + setSavedFilters(parseWorkspaceViewFiltersFromSearchParams(params)); + } else { + setSavedFilters(parseWorkspaceViewFiltersFromSearchParams(new URLSearchParams())); + } + } catch { + setViewTitle('View'); + } + }; + useEffect(() => { let cancelled = false; void (async () => { try { const v = await viewService.get(workspaceSlug, viewId); - if (!cancelled) setViewTitle(v?.name?.trim() ? v.name : 'View'); + if (cancelled) return; + setViewTitle(v?.name?.trim() ? v.name : 'View'); + const raw = v?.filters; + if (raw && typeof raw === 'object' && !Array.isArray(raw)) { + const params = new URLSearchParams(); + for (const [k, val] of Object.entries(raw as Record)) { + if (val == null) continue; + const s = String(val).trim(); + if (s) params.set(k, s); + } + setSavedFilters(parseWorkspaceViewFiltersFromSearchParams(params)); + } else { + setSavedFilters(parseWorkspaceViewFiltersFromSearchParams(new URLSearchParams())); + } } catch { if (!cancelled) setViewTitle('View'); } @@ -3191,6 +3247,47 @@ function ProjectSavedViewDetailHeader({ }; }, [workspaceSlug, viewId]); + // Reload the snapshot when the view is edited from elsewhere (rename/etc. + // dispatch this event) so the comparison against saved filters stays fresh. + useEffect(() => { + const handler = () => { + void refreshView.current(); + }; + window.addEventListener(PROJECT_VIEWS_REFRESH_EVENT, handler); + return () => window.removeEventListener(PROJECT_VIEWS_REFRESH_EVENT, handler); + }, []); + + // Dirty detection: serialize both filter sets to the same canonical + // search-params record and string-compare. Cheap and good enough. + const filtersDirty = (() => { + if (!savedFilters) return false; + const a = JSON.stringify(workspaceViewFiltersToSearchParams(workspaceViewFilters)); + const b = JSON.stringify(workspaceViewFiltersToSearchParams(savedFilters)); + return a !== b; + })(); + + const handleSaveFilters = async () => { + if (!filtersDirty || savingFilters) return; + setSavingFilters(true); + try { + const payload = workspaceViewFiltersToSearchParams(workspaceViewFilters); + await viewService.update(workspaceSlug, viewId, { + filters: payload as Record, + }); + setSavedFilters(workspaceViewFilters); + window.dispatchEvent(new CustomEvent(PROJECT_VIEWS_REFRESH_EVENT)); + } catch { + // Surface no toast — the dirty banner remains so the user can retry. + } finally { + setSavingFilters(false); + } + }; + + const handleResetFilters = () => { + if (!savedFilters) return; + setWorkspaceViewFilters(savedFilters); + }; + useEffect(() => { let cancelled = false; projectService @@ -3389,6 +3486,27 @@ function ProjectSavedViewDetailHeader({ )}
+ {filtersDirty && ( + <> + + + + )} + {projectDropdownOpen && ( +
+
+ + + + setProjectSearch(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none" + /> +
+
+ {filteredProjects.map((p) => ( + + ))} +
+
+ )} + + > + + + + + + Pages + + {breadcrumb ? ( + <> + + > + +
+ {breadcrumb} +
+ + ) : null} + +
{actions}
+ + ); +} + // --------------------------------------------------------------------------- // PageHeader // --------------------------------------------------------------------------- export function PageHeader() { const location = useLocation(); - const { workspaceSlug, projectId, moduleId, viewId } = useParams<{ + const { workspaceSlug, projectId, moduleId, viewId, pageId } = useParams<{ workspaceSlug?: string; projectId?: string; moduleId?: string; viewId?: string; + pageId?: string; }>(); const [workspace, setWorkspace] = useState(null); const [projects, setProjects] = useState([]); @@ -3542,6 +3821,8 @@ export function PageHeader() { const isProjectSavedViewDetailPage = projectBase && !!viewId && pathNoTrailingSlash === `${projectBase}/views/${viewId}`; const isPagesPage = projectBase && pathname === `${projectBase}/pages`; + const isPageDetailPage = + projectBase && !!pageId && pathNoTrailingSlash === `${projectBase}/pages/${pageId}`; const isProjectSection = isIssuesPage || isCyclesPage || isModulesPage || isViewsListPage || isPagesPage; const isProjectDetail = @@ -3610,6 +3891,10 @@ export function PageHeader() { issueCount={projectIssueCount} /> ); + } else if (isPageDetailPage && workspaceSlug && projectId && project) { + content = ( + + ); } else if (isProjectSection && workspaceSlug && projectId && project && projectSection) { content = (