Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
102 changes: 102 additions & 0 deletions backend/internal/httpd/apispec/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,82 @@ paths:
summary: List unread notifications
tags:
- notifications
/api/v1/notifications/{id}:
patch:
operationId: markNotificationRead
parameters:
- description: Notification identifier.
in: path
name: id
required: true
schema:
description: Notification identifier.
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/MarkNotificationReadRequest'
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/NotificationEnvelope'
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Bad Request
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Mark a notification read
tags:
- notifications
/api/v1/notifications/read-all:
post:
operationId: markAllNotificationsRead
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/MarkAllNotificationsReadResponse'
description: OK
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Internal Server Error
"501":
content:
application/json:
schema:
$ref: '#/components/schemas/APIError'
description: Not Implemented
summary: Mark all unread notifications read
tags:
- notifications
/api/v1/notifications/stream:
get:
operationId: streamNotifications
Expand Down Expand Up @@ -1357,6 +1433,25 @@ components:
required:
- sessions
type: object
MarkAllNotificationsReadResponse:
properties:
notifications:
items:
$ref: '#/components/schemas/NotificationResponse'
type: array
required:
- notifications
type: object
MarkNotificationReadRequest:
properties:
status:
description: V1 supports only marking an unread notification read.
enum:
- read
type: string
required:
- status
type: object
MergePRResponse:
properties:
method:
Expand All @@ -1370,6 +1465,13 @@ components:
- prNumber
- method
type: object
NotificationEnvelope:
properties:
notification:
$ref: '#/components/schemas/NotificationResponse'
required:
- notification
type: object
NotificationResponse:
properties:
body:
Expand Down
90 changes: 58 additions & 32 deletions backend/internal/httpd/apispec/specgen/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,38 +130,42 @@ var schemaNames = map[string]string{
"DomainAgentConfig": "AgentConfig",
"DomainRoleOverride": "RoleOverride",
// httpd/controllers (wire envelopes)
"ControllersListProjectsResponse": "ListProjectsResponse",
"ControllersProjectResponse": "ProjectResponse",
"ControllersGetProjectResponse": "ProjectGetResponse",
"ControllersProjectOrDegraded": "ProjectOrDegraded",
"ControllersListSessionsQuery": "ListSessionsQuery",
"ControllersCleanupSessionsQuery": "CleanupSessionsQuery",
"ControllersListSessionsResponse": "ListSessionsResponse",
"ControllersSpawnSessionRequest": "SpawnSessionRequest",
"ControllersSessionResponse": "SessionResponse",
"ControllersRenameSessionRequest": "RenameSessionRequest",
"ControllersRenameSessionResponse": "RenameSessionResponse",
"ControllersRestoreSessionResponse": "RestoreSessionResponse",
"ControllersCleanupSessionsResponse": "CleanupSessionsResponse",
"ControllersCleanupSkippedSession": "CleanupSkippedSession",
"ControllersKillSessionResponse": "KillSessionResponse",
"ControllersRollbackSessionResponse": "RollbackSessionResponse",
"ControllersSendSessionMessageRequest": "SendSessionMessageRequest",
"ControllersSendSessionMessageResponse": "SendSessionMessageResponse",
"ControllersClaimPRResponse": "ClaimPRResponse",
"ControllersClaimPRRequest": "ClaimPRRequest",
"ControllersSessionPRFacts": "SessionPRFacts",
"ControllersListSessionPRsResponse": "ListSessionPRsResponse",
"ControllersSetActivityRequest": "SetActivityRequest",
"ControllersSetActivityResponse": "SetActivityResponse",
"ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest",
"ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse",
"ControllersOrchestratorResponse": "OrchestratorResponse",
"ControllersListNotificationsQuery": "ListNotificationsQuery",
"ControllersNotificationStreamQuery": "NotificationStreamQuery",
"ControllersNotificationTarget": "NotificationTarget",
"ControllersNotificationResponse": "NotificationResponse",
"ControllersListNotificationsResponse": "ListNotificationsResponse",
"ControllersListProjectsResponse": "ListProjectsResponse",
"ControllersProjectResponse": "ProjectResponse",
"ControllersGetProjectResponse": "ProjectGetResponse",
"ControllersProjectOrDegraded": "ProjectOrDegraded",
"ControllersListSessionsQuery": "ListSessionsQuery",
"ControllersCleanupSessionsQuery": "CleanupSessionsQuery",
"ControllersListSessionsResponse": "ListSessionsResponse",
"ControllersSpawnSessionRequest": "SpawnSessionRequest",
"ControllersSessionResponse": "SessionResponse",
"ControllersRenameSessionRequest": "RenameSessionRequest",
"ControllersRenameSessionResponse": "RenameSessionResponse",
"ControllersRestoreSessionResponse": "RestoreSessionResponse",
"ControllersCleanupSessionsResponse": "CleanupSessionsResponse",
"ControllersCleanupSkippedSession": "CleanupSkippedSession",
"ControllersKillSessionResponse": "KillSessionResponse",
"ControllersRollbackSessionResponse": "RollbackSessionResponse",
"ControllersSendSessionMessageRequest": "SendSessionMessageRequest",
"ControllersSendSessionMessageResponse": "SendSessionMessageResponse",
"ControllersClaimPRResponse": "ClaimPRResponse",
"ControllersClaimPRRequest": "ClaimPRRequest",
"ControllersSessionPRFacts": "SessionPRFacts",
"ControllersListSessionPRsResponse": "ListSessionPRsResponse",
"ControllersSetActivityRequest": "SetActivityRequest",
"ControllersSetActivityResponse": "SetActivityResponse",
"ControllersSpawnOrchestratorRequest": "SpawnOrchestratorRequest",
"ControllersSpawnOrchestratorResponse": "SpawnOrchestratorResponse",
"ControllersOrchestratorResponse": "OrchestratorResponse",
"ControllersListNotificationsQuery": "ListNotificationsQuery",
"ControllersNotificationStreamQuery": "NotificationStreamQuery",
"ControllersNotificationIDParam": "NotificationIDParam",
"ControllersNotificationTarget": "NotificationTarget",
"ControllersNotificationResponse": "NotificationResponse",
"ControllersListNotificationsResponse": "ListNotificationsResponse",
"ControllersMarkNotificationReadRequest": "MarkNotificationReadRequest",
"ControllersNotificationEnvelope": "NotificationEnvelope",
"ControllersMarkAllNotificationsReadResponse": "MarkAllNotificationsReadResponse",
// httpd/controllers — PR wire envelopes
"ControllersMergePRResponse": "MergePRResponse",
"ControllersResolveCommentsRequest": "ResolveCommentsRequest",
Expand Down Expand Up @@ -275,6 +279,28 @@ func notificationOperations() []operation {
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodPatch, path: "/api/v1/notifications/{id}", id: "markNotificationRead", tag: "notifications",
summary: "Mark a notification read",
pathParams: []any{controllers.NotificationIDParam{}},
reqBody: controllers.MarkNotificationReadRequest{},
resps: []respUnit{
{http.StatusOK, controllers.NotificationEnvelope{}},
{http.StatusBadRequest, envelope.APIError{}},
{http.StatusNotFound, envelope.APIError{}},
{http.StatusInternalServerError, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodPost, path: "/api/v1/notifications/read-all", id: "markAllNotificationsRead", tag: "notifications",
summary: "Mark all unread notifications read",
resps: []respUnit{
{http.StatusOK, controllers.MarkAllNotificationsReadResponse{}},
{http.StatusInternalServerError, envelope.APIError{}},
{http.StatusNotImplemented, envelope.APIError{}},
},
},
{
method: http.MethodGet, path: "/api/v1/notifications/stream", id: "streamNotifications", tag: "notifications",
summary: "Stream created notifications",
Expand Down
20 changes: 20 additions & 0 deletions backend/internal/httpd/controllers/dto.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,11 @@ type NotificationStreamQuery struct {
ProjectID string `query:"projectId,omitempty" description:"Optional project id filter for live notifications."`
}

// NotificationIDParam is the {id} path parameter shared by notification routes.
type NotificationIDParam struct {
ID string `path:"id" description:"Notification identifier."`
}

// NotificationTarget is the dashboard navigation target for a notification.
type NotificationTarget struct {
Kind string `json:"kind" enum:"session,pr"`
Expand All @@ -299,6 +304,21 @@ type ListNotificationsResponse struct {
Notifications []NotificationResponse `json:"notifications"`
}

// MarkNotificationReadRequest is the body of PATCH /api/v1/notifications/{id}.
type MarkNotificationReadRequest struct {
Status string `json:"status" enum:"read" description:"V1 supports only marking an unread notification read."`
}

// NotificationEnvelope is the { notification } response body for notification mutations.
type NotificationEnvelope struct {
Notification NotificationResponse `json:"notification"`
}

// MarkAllNotificationsReadResponse is the body of POST /api/v1/notifications/read-all.
type MarkAllNotificationsReadResponse struct {
Notifications []NotificationResponse `json:"notifications"`
}

// PRIDParam is the {id} path parameter shared by the /prs/{id} routes.
type PRIDParam struct {
ID string `path:"id" description:"PR number."`
Expand Down
75 changes: 59 additions & 16 deletions backend/internal/httpd/controllers/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
// NotificationService is the controller-facing notification service contract.
type NotificationService interface {
ListUnread(ctx context.Context, filter notificationsvc.ListFilter) ([]notificationsvc.Notification, error)
MarkRead(ctx context.Context, id string) (notificationsvc.Notification, bool, error)
MarkAllRead(ctx context.Context) ([]notificationsvc.Notification, error)
}

// NotificationStream is the live notification stream used by SSE clients.
Expand All @@ -34,6 +36,8 @@ type NotificationsController struct {
// Register mounts bounded notification REST routes on the supplied router.
func (c *NotificationsController) Register(r chi.Router) {
r.Get("/notifications", c.list)
r.Post("/notifications/read-all", c.markAllRead)
r.Patch("/notifications/{id}", c.markRead)
}

// RegisterStream mounts long-lived notification stream routes on the supplied router.
Expand All @@ -59,6 +63,41 @@ func (c *NotificationsController) list(w http.ResponseWriter, r *http.Request) {
envelope.WriteJSON(w, http.StatusOK, ListNotificationsResponse{Notifications: notificationResponses(notifications)})
}

func (c *NotificationsController) markRead(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "PATCH", "/api/v1/notifications/{id}")
return
}
var req MarkNotificationReadRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_JSON", "Invalid JSON body", nil)
return
}
if req.Status != string(domain.NotificationRead) {
envelope.WriteAPIError(w, r, http.StatusBadRequest, "bad_request", "INVALID_NOTIFICATION_STATUS", "Notification status must be read", nil)
return
}
notification, _, err := c.Svc.MarkRead(r.Context(), chi.URLParam(r, "id"))
if err != nil {
envelope.WriteError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, NotificationEnvelope{Notification: notificationResponse(notification)})
}

func (c *NotificationsController) markAllRead(w http.ResponseWriter, r *http.Request) {
if c.Svc == nil {
apispec.NotImplemented(w, r, "POST", "/api/v1/notifications/read-all")
return
}
notifications, err := c.Svc.MarkAllRead(r.Context())
if err != nil {
envelope.WriteError(w, r, err)
return
}
envelope.WriteJSON(w, http.StatusOK, MarkAllNotificationsReadResponse{Notifications: notificationResponses(notifications)})
}

func (c *NotificationsController) stream(w http.ResponseWriter, r *http.Request) {
if c.Stream == nil {
apispec.NotImplemented(w, r, "GET", "/api/v1/notifications/stream")
Expand Down Expand Up @@ -142,26 +181,30 @@ func (e notificationQueryError) Error() string { return string(e) }
func notificationResponses(in []notificationsvc.Notification) []NotificationResponse {
out := make([]NotificationResponse, 0, len(in))
for _, n := range in {
out = append(out, NotificationResponse{
ID: n.ID,
SessionID: string(n.SessionID),
ProjectID: string(n.ProjectID),
PRURL: n.PRURL,
Type: string(n.Type),
Title: n.Title,
Body: n.Body,
Status: string(n.Status),
CreatedAt: n.CreatedAt,
Target: NotificationTarget{
Kind: string(n.Target.Kind),
SessionID: string(n.Target.SessionID),
PRURL: n.Target.PRURL,
},
})
out = append(out, notificationResponse(n))
}
return out
}

func notificationResponse(n notificationsvc.Notification) NotificationResponse {
return NotificationResponse{
ID: n.ID,
SessionID: string(n.SessionID),
ProjectID: string(n.ProjectID),
PRURL: n.PRURL,
Type: string(n.Type),
Title: n.Title,
Body: n.Body,
Status: string(n.Status),
CreatedAt: n.CreatedAt,
Target: NotificationTarget{
Kind: string(n.Target.Kind),
SessionID: string(n.Target.SessionID),
PRURL: n.Target.PRURL,
},
}
}

func notificationResponseFromRecord(rec domain.NotificationRecord) NotificationResponse {
return NotificationResponse{
ID: rec.ID,
Expand Down
Loading