Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
3d9760d
feat(api): add IssueSubscriber model for the activity follow list
martian56 May 5, 2026
79bbd4e
feat(api): add PageVersion model for page-content history
martian56 May 5, 2026
e86b6b5
feat(api): add notification sender constants and snoozed_till column
martian56 May 5, 2026
4f0dcb4
feat(api): expand Page model with color, view/logo props, and access …
martian56 May 5, 2026
a1d32b5
feat(api): parse TipTap mention spans into user IDs
martian56 May 5, 2026
a09ae96
test(api): cover mention parsing edge cases
martian56 May 5, 2026
cbaa8a5
feat(api): issue subscriber store with idempotent subscribe
martian56 May 5, 2026
1f918a4
feat(api): notification store supports snooze, archive, and unread-count
martian56 May 5, 2026
e6892b9
feat(api): page store supports versions, lock, archive, and content u…
martian56 May 5, 2026
74d4f7a
feat(api): page favorite helpers on user-favorite store
martian56 May 5, 2026
10bc3e9
refactor(api): rebuild notification service with sender-aware fan-out
martian56 May 5, 2026
6dc93b1
test(api): cover notification title, message, dedup, and preview helpers
martian56 May 5, 2026
8f0630d
refactor(api): page service with permissions, versions, lock, archive…
martian56 May 5, 2026
5fe1e0e
test(api): cover page permission helpers and HTML stripping
martian56 May 5, 2026
dbd5c4e
feat(api): emit notifications and auto-subscribe on issue events
martian56 May 5, 2026
b139fb2
feat(api): emit notifications and auto-subscribe on comment events
martian56 May 5, 2026
febbafc
feat(api): issue subscribe / unsubscribe / status endpoints
martian56 May 5, 2026
a8c3923
feat(api): notification snooze, archive, mark-unread, and unread-coun…
martian56 May 5, 2026
093e5ca
refactor(api): page handlers for content autosave, lock, archive, ver…
martian56 May 5, 2026
0f99394
feat(api): wire subscriber store, notification prefs, and new page+no…
martian56 May 5, 2026
b57c58a
chore(api): bump x/crypto and tidy indirect deps for mention HTML par…
martian56 May 5, 2026
5b8b751
feat(ui): add notification message, sender, page-version, and unread-…
martian56 May 5, 2026
c436dbe
feat(ui): issueService supports subscribe, unsubscribe, and isSubscribed
martian56 May 5, 2026
c66cd63
feat(ui): notificationService supports unread-count, mentions, snooze…
martian56 May 5, 2026
22df023
feat(ui): pageService supports content autosave, versions, lock, arch…
martian56 May 5, 2026
9d39df1
feat(ui): notification bell with unread badge and visibility-aware po…
martian56 May 5, 2026
028e4d7
feat(ui): notification content renders sender-specific inbox prose
martian56 May 5, 2026
298bfb7
feat(ui): snooze menu with presets and custom datetime input
martian56 May 5, 2026
f9e191e
feat(ui): subscribe button toggle for issue activity stream
martian56 May 5, 2026
d0dc1fe
feat(ui): mount notification bell and page breadcrumb in the header
martian56 May 5, 2026
46c2ec6
feat(ui): wire subscribe button on the issue detail page
martian56 May 5, 2026
1a3495f
refactor(ui): rebuild notifications page with tabs, snooze, and archi…
martian56 May 5, 2026
719d155
refactor(ui): rebuild pages list with archived tab, search, and sub-p…
martian56 May 5, 2026
a67aaac
feat(ui): page detail page with autosave editor, versions panel, and …
martian56 May 5, 2026
9f312d7
feat(ui): mount page detail route under projects
martian56 May 5, 2026
3910991
chore(ui): bump UI version to 0.7.0
martian56 May 5, 2026
a054db2
fix(api): fix code formatting
martian56 May 5, 2026
e024cfb
fix: fix copilot warnings
martian56 May 5, 2026
bb814c5
Potential fix for pull request finding 'Useless conditional'
martian56 May 5, 2026
da63627
fix(api): scope MarkAllRead to active inbox and add sqlite CountUnrea…
martian56 May 5, 2026
97998e8
feat(api): transactional helpers for page create-with-link, duplicate…
martian56 May 5, 2026
525ba49
fix(api): rune-safe truncation in stripPreview to keep UTF-8 intact
martian56 May 5, 2026
dac1731
fix(api): page parent must be view-permitted; tighten ownership-vs-me…
martian56 May 5, 2026
b3fb4ca
feat(api): page UpdateMeta logo_props tri-state and required descript…
martian56 May 5, 2026
abda8fc
refactor(ui): drop unused IssueView.anchor and align page response types
martian56 May 5, 2026
e9e48c6
refactor(ui): remove dead publish/unpublish methods from viewService
martian56 May 5, 2026
b7301ee
feat(ui): page event bus for cross-component refresh and modal toggles
martian56 May 5, 2026
031aa86
feat(ui): page-detail header context exposes save state to PageHeader
martian56 May 5, 2026
ce2c7ac
feat(ui): TipTap-based page-editor package with toolbar, color, typog…
martian56 May 5, 2026
e7ff7ed
refactor(ui): drop legacy PageDescriptionEditor in favor of the page-…
martian56 May 5, 2026
de0907f
fix(ui): associate snooze custom-time label with input via htmlFor
martian56 May 5, 2026
377eded
feat(ui): active-filter pills row for the project saved-view detail page
martian56 May 5, 2026
5e7f77d
refactor(ui): AppShell hosts page-detail header context
martian56 May 5, 2026
eb3db59
feat(ui): save/reset filters in saved-view header and page-detail bre…
martian56 May 5, 2026
7d2e569
fix(ui): mark-unread persists, optional-chain message access, accessi…
martian56 May 5, 2026
83ec59a
fix(ui): page autosave race-safety, unmount flush, version reset on p…
martian56 May 5, 2026
4dd3c83
refactor(ui): pages list polish and cleanup of stale eslint suppressions
martian56 May 5, 2026
89772d2
feat(ui): project Views list - active-filter pills row, relative upda…
martian56 May 5, 2026
58b736c
feat(ui): project Views detail - title bar with count and active-filt…
martian56 May 5, 2026
50622a6
feat(ui): styles for page-editor and saved-view filter pills
martian56 May 5, 2026
0a43e16
chore(ui): bump UI version to 0.7.1
martian56 May 5, 2026
7f3ce1b
fix(ui): fix format and lint issues
martian56 May 5, 2026
ce817d5
fix: normalize the page header
martian56 May 7, 2026
5902080
1.2.1
martian56 May 7, 2026
96331db
chore: bump ui version
martian56 May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions api/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
88 changes: 88 additions & 0 deletions api/internal/handler/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
178 changes: 175 additions & 3 deletions api/internal/handler/notification.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -15,16 +17,31 @@ 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 {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication required"})
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
Comment thread
martian56 marked this conversation as resolved.
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"})
Expand All @@ -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) {
Expand All @@ -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) {
Expand Down
Loading
Loading