diff --git a/server/app/app.go b/server/app/app.go index da38e776..d2423dc0 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -159,7 +159,9 @@ func (a *App) registerHandlers() { invoiceRouter.HandleFunc("/pay/{id}", WrapFunc(a.PayInvoiceHandler)).Methods("PUT", "OPTIONS") notificationRouter.HandleFunc("", WrapFunc(a.ListNotificationsHandler)).Methods("GET", "OPTIONS") + notificationRouter.HandleFunc("/stream", a.sseNotificationsHandler).Methods("GET", "OPTIONS") notificationRouter.HandleFunc("/{id}", WrapFunc(a.UpdateNotificationsHandler)).Methods("PUT", "OPTIONS") + notificationRouter.HandleFunc("", WrapFunc(a.SeenNotificationsHandler)).Methods("PUT", "OPTIONS") regionRouter.HandleFunc("", WrapFunc(a.ListRegionsHandler)).Methods("GET", "OPTIONS") diff --git a/server/app/notification_handler.go b/server/app/notification_handler.go index c3e7dfc9..1e03b79a 100644 --- a/server/app/notification_handler.go +++ b/server/app/notification_handler.go @@ -2,9 +2,11 @@ package app import ( + "encoding/json" "errors" "net/http" "strconv" + "time" "github.com/codescalers/cloud4students/middlewares" "github.com/gorilla/mux" @@ -12,6 +14,67 @@ import ( "gorm.io/gorm" ) +// UpdateNotificationsHandler updates notifications for a user +// Example endpoint: Set user's notifications as seen +// @Summary Set user's notifications as seen +// @Description Set user's notifications as seen +// @Tags Notification +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Notification ID" +// @Success 200 {object} Response +// @Failure 400 {object} Response +// @Failure 401 {object} Response +// @Failure 500 {object} Response +// @Router /notification/{id} [put] +func (a *App) UpdateNotificationsHandler(req *http.Request) (interface{}, Response) { + id, err := strconv.Atoi(mux.Vars(req)["id"]) + if err != nil { + log.Error().Err(err).Send() + return nil, BadRequest(errors.New("failed to read notification id")) + } + + err = a.db.UpdateNotification(id, true) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + return ResponseMsg{ + Message: "Notifications are updated", + Data: nil, + }, Ok() +} + +// SeenNotificationsHandler updates notifications for a user to be seen +// Example endpoint: Set user's notifications as seen +// @Summary Set user's notifications as seen +// @Description Set user's notifications as seen +// @Tags Notification +// @Accept json +// @Produce json +// @Security BearerAuth +// @Success 200 {object} Response +// @Failure 400 {object} Response +// @Failure 401 {object} Response +// @Failure 500 {object} Response +// @Router /notification [put] +func (a *App) SeenNotificationsHandler(req *http.Request) (interface{}, Response) { + userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) + + err := a.db.UpdateUserNotification(userID, true) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + return ResponseMsg{ + Message: "Notifications are seen", + Data: nil, + }, Ok() +} + // ListNotificationsHandler lists notifications for a user // Example endpoint: Lists user's notifications // @Summary Lists user's notifications @@ -47,35 +110,73 @@ func (a *App) ListNotificationsHandler(req *http.Request) (interface{}, Response }, Ok() } -// UpdateNotificationsHandler updates notifications for a user -// Example endpoint: Set user's notifications as seen -// @Summary Set user's notifications as seen -// @Description Set user's notifications as seen +// sseNotificationsHandler to stream notifications +// Example endpoint: Stream user's notifications +// @Summary Stream user's notifications +// @Description Stream user's notifications // @Tags Notification // @Accept json // @Produce json // @Security BearerAuth -// @Param id path string true "Notification ID" -// @Success 200 {object} Response -// @Failure 400 {object} Response +// @Success 200 {object} []models.Notification // @Failure 401 {object} Response // @Failure 500 {object} Response -// @Router /notification/{id} [put] -func (a *App) UpdateNotificationsHandler(req *http.Request) (interface{}, Response) { - id, err := strconv.Atoi(mux.Vars(req)["id"]) - if err != nil { - log.Error().Err(err).Send() - return nil, BadRequest(errors.New("failed to read notification id")) +// @Router /notification/stream [get] +func (a *App) sseNotificationsHandler(w http.ResponseWriter, req *http.Request) { + userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + // Flush the headers immediately + flusher, ok := w.(http.Flusher) + if !ok { + log.Error().Msg("Streaming unsupported") + internalServerError(w) + return } - err = a.db.UpdateNotification(id, true) - if err != nil { - log.Error().Err(err).Send() - return nil, InternalServerError(errors.New(internalServerErrorMsg)) + // Sending notifications every 5 seconds + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + notifications, err := a.db.GetNewNotifications(userID) + if err != nil { + log.Error().Err(err).Send() + internalServerError(w) + return + } + + // Send each notification as a separate SSE message + for _, notification := range notifications { + if _, err := w.Write([]byte(notification.Msg)); err != nil { + log.Error().Err(err).Send() + internalServerError(w) + return + } + flusher.Flush() // Ensure the event is sent immediately + } + + case <-req.Context().Done(): + w.WriteHeader(http.StatusOK) + return + } } +} - return ResponseMsg{ - Message: "Notifications are updated", - Data: nil, - }, Ok() +func internalServerError(w http.ResponseWriter) { + w.WriteHeader(http.StatusInternalServerError) + object := struct { + Error string `json:"err"` + }{ + Error: "Internal server error", + } + + if err := json.NewEncoder(w).Encode(object); err != nil { + log.Error().Err(err).Msg("failed to encode return object") + } } diff --git a/server/docs/docs.go b/server/docs/docs.go index f497c792..298a06a0 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -1008,6 +1008,81 @@ const docTemplate = `{ "schema": {} } } + }, + "put": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Set user's notifications as seen", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "Set user's notifications as seen", + "responses": { + "200": { + "description": "OK", + "schema": {} + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, + "/notification/stream": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Stream user's notifications", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Notification" + ], + "summary": "Stream user's notifications", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Notification" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } } }, "/notification/{id}": { @@ -3279,6 +3354,7 @@ const docTemplate = `{ "type": "object", "required": [ "msg", + "notified", "seen", "type", "user_id" @@ -3290,11 +3366,14 @@ const docTemplate = `{ "msg": { "type": "string" }, + "notified": { + "type": "boolean" + }, "seen": { "type": "boolean" }, "type": { - "description": "to allow redirecting from notifications to the right pages", + "description": "to allow redirecting from notifications to the right pages\nfor example if the type is ` + "`" + `vm` + "`" + ` it will be redirected to the vm page", "type": "string" }, "user_id": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index ba534271..28816e6e 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -461,15 +461,20 @@ definitions: type: integer msg: type: string + notified: + type: boolean seen: type: boolean type: - description: to allow redirecting from notifications to the right pages + description: |- + to allow redirecting from notifications to the right pages + for example if the type is `vm` it will be redirected to the vm page type: string user_id: type: string required: - msg + - notified - seen - type - user_id @@ -1296,6 +1301,30 @@ paths: summary: Lists user's notifications tags: - Notification + put: + consumes: + - application/json + description: Set user's notifications as seen + produces: + - application/json + responses: + "200": + description: OK + schema: {} + "400": + description: Bad Request + schema: {} + "401": + description: Unauthorized + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - BearerAuth: [] + summary: Set user's notifications as seen + tags: + - Notification /notification/{id}: put: consumes: @@ -1327,6 +1356,31 @@ paths: summary: Set user's notifications as seen tags: - Notification + /notification/stream: + get: + consumes: + - application/json + description: Stream user's notifications + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.Notification' + type: array + "401": + description: Unauthorized + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - BearerAuth: [] + summary: Stream user's notifications + tags: + - Notification /region: get: consumes: diff --git a/server/models/notification.go b/server/models/notification.go index 1bf0f4ae..5fc1664d 100644 --- a/server/models/notification.go +++ b/server/models/notification.go @@ -10,11 +10,13 @@ const ( // Notification struct holds data of notifications type Notification struct { - ID int `json:"id" gorm:"primaryKey"` - UserID string `json:"user_id" binding:"required"` - Msg string `json:"msg" binding:"required"` - Seen bool `json:"seen" binding:"required"` + ID int `json:"id" gorm:"primaryKey"` + UserID string `json:"user_id" binding:"required"` + Msg string `json:"msg" binding:"required"` + Seen bool `json:"seen" binding:"required"` + Notified bool `json:"notified" binding:"required"` // to allow redirecting from notifications to the right pages + // for example if the type is `vm` it will be redirected to the vm page Type string `json:"type" binding:"required"` } @@ -25,11 +27,27 @@ func (d *DB) ListNotifications(userID string) ([]Notification, error) { return res, query.Error } +// GetNewNotifications returns a list of new notifications for a user. +func (d *DB) GetNewNotifications(userID string) ([]Notification, error) { + var res []Notification + query := d.db.Where("user_id = ?", userID).Where("notified = ?", false).Find(&res) + if query.Error != nil { + return nil, query.Error + } + + return res, d.db.Model(&Notification{}).Where("user_id = ?", userID).Updates(map[string]interface{}{"notified": true}).Error +} + // UpdateNotification updates seen field for notification func (d *DB) UpdateNotification(id int, seen bool) error { return d.db.Model(&Notification{}).Where("id = ?", id).Updates(map[string]interface{}{"seen": seen}).Error } +// UpdateUserNotification updates seen field for user notifications +func (d *DB) UpdateUserNotification(userID string, seen bool) error { + return d.db.Model(&Notification{}).Where("user_id = ?", userID).Updates(map[string]interface{}{"seen": seen}).Error +} + // CreateNotification adds a new notification for a user func (d *DB) CreateNotification(n *Notification) error { return d.db.Create(&n).Error