diff --git a/README.md b/README.md index b9f1e62..bb289be 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ the application. | `PUT` | `/fmsg/:id` | Update a draft message | | `DELETE` | `/fmsg/:id` | Delete a draft message | | `POST` | `/fmsg/:id/send` | Send a message | +| `POST` | `/fmsg/:id/read` | Mark a message as read | | `POST` | `/fmsg/:id/add-to` | Add recipients | | `GET` | `/fmsg/:id/data` | Download message data | | `POST` | `/fmsg/:id/attach` | Upload an attachment | @@ -199,7 +200,7 @@ Returns messages where the authenticated user is a recipient (listed in `msg_to` | `limit` | `20` | Max messages to return (1–100) | | `offset` | `0` | Number of messages to skip for pagination | -**Response:** JSON array of message objects. Each object has the same shape as the single-message response from `GET /fmsg/:id` (with an additional `id` field). Message body data and attachment contents are not included — use the dedicated download endpoints instead. +**Response:** JSON array of message objects. Each object has the same shape as the single-message response from `GET /fmsg/:id` (with an additional `id` field), including the `read`/`time_read` fields reflecting the caller's per-recipient read state. Message body data and attachment contents are not included — use the dedicated download endpoints instead. ### GET `/fmsg/sent` @@ -272,10 +273,16 @@ Retrieves a single message by ID. The authenticated user must be a participant "type": "text/plain", "size": 12, "short_text": "hello world", + "read": false, + "time_read": null, "attachments": [] } ``` +The `read` and `time_read` fields reflect the calling user's per-recipient +read state (set by `POST /fmsg/:id/read`). For the sender's own messages +they are always `false`/`null` (read state is recipient-scoped). + The `short_text` field is included only when the message `type` is `text/*` and the stored body is valid UTF-8. When `FMSG_API_SHORT_TEXT_SIZE` is greater than `0`, it contains up to that many bytes (default 768) of the body, truncated on @@ -329,6 +336,20 @@ Marks a draft message as sent by setting `time_sent` to the current timestamp. O | `404` | Message not found | | `409` | Message already sent | +### POST `/fmsg/:id/read` + +Marks a message as read by the authenticated recipient by setting `time_read` +on the caller's `msg_to` or `msg_add_to` row. Idempotent: re-reading an +already-read message returns the original `time_read` without updating it. + +**Response:** `200 OK` with `{"id": , "time_read": }`. + +**Errors:** + +| Status | Condition | +| ------ | --------- | +| `404` | Authenticated user is not a recipient of this message (or message does not exist) | + ### POST `/fmsg/:id/add-to` Adds additional recipients to an existing message. The authenticated user must be an existing participant — the sender (`from`) or a primary recipient (listed in `to`). diff --git a/src/handlers/messages.go b/src/handlers/messages.go index 897b923..a7a57f2 100644 --- a/src/handlers/messages.go +++ b/src/handlers/messages.go @@ -57,6 +57,8 @@ type messageListItem struct { Type string `json:"type"` Size int `json:"size"` ShortText string `json:"short_text,omitempty"` + Read bool `json:"read"` + TimeRead *float64 `json:"time_read"` Attachments []models.Attachment `json:"attachments"` } @@ -189,7 +191,11 @@ func (h *MessageHandler) List(c *gin.Context) { ctx := c.Request.Context() rows, err := h.DB.Pool.Query(ctx, - `SELECT m.id, m.version, m.pid, m.no_reply, m.is_important, m.is_deflate, m.time_sent, m.from_addr, m.topic, m.type, m.size, m.filepath + `SELECT m.id, m.version, m.pid, m.no_reply, m.is_important, m.is_deflate, m.time_sent, m.from_addr, m.topic, m.type, m.size, m.filepath, + COALESCE( + (SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = m.id AND mt.addr = $1), + (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = m.id AND mat.addr = $1) + ) AS time_read FROM msg m WHERE EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = m.id AND mt.addr = $1) OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = m.id AND mat.addr = $1) @@ -209,12 +215,13 @@ func (h *MessageHandler) List(c *gin.Context) { for rows.Next() { var m messageListItem var dataPath string - if err := rows.Scan(&m.ID, &m.Version, &m.PID, &m.NoReply, &m.Important, &m.Deflate, &m.Time, &m.From, &m.Topic, &m.Type, &m.Size, &dataPath); err != nil { + if err := rows.Scan(&m.ID, &m.Version, &m.PID, &m.NoReply, &m.Important, &m.Deflate, &m.Time, &m.From, &m.Topic, &m.Type, &m.Size, &dataPath, &m.TimeRead); err != nil { log.Printf("list messages scan: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"}) return } m.HasPid = m.PID != nil + m.Read = m.TimeRead != nil m.ShortText = h.extractShortText(dataPath, m.Type) messages = append(messages, m) msgIDs = append(msgIDs, m.ID) @@ -536,6 +543,23 @@ func (h *MessageHandler) Get(c *gin.Context) { // Compute ShortText only after authorization has been confirmed. msg.ShortText = h.extractShortText(dataPath, msg.Type) + // Populate per-recipient read state for the calling user. The sender + // has no read state of their own. + if msg.From != identity { + var timeRead *float64 + err := h.DB.Pool.QueryRow(ctx, + `SELECT COALESCE( + (SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = $1 AND mt.addr = $2), + (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = $1 AND mat.addr = $2) + )`, + msgID, identity, + ).Scan(&timeRead) + if err == nil { + msg.TimeRead = timeRead + msg.Read = timeRead != nil + } + } + c.JSON(http.StatusOK, msg) } @@ -812,6 +836,75 @@ func (h *MessageHandler) Send(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"id": msgID, "time": now}) } +// MarkRead handles POST /fmsg/:id/read — marks a message as read by the +// calling recipient. Idempotent: re-reading an already-read message returns +// the original time_read without updating it. +func (h *MessageHandler) MarkRead(c *gin.Context) { + identity := middleware.GetIdentity(c) + msgID, ok := parseID(c) + if !ok { + return + } + + ctx := c.Request.Context() + + // Look up the caller's existing time_read across both recipient tables. + // COALESCE returns NULL if the user is not a recipient at all (in which + // case both subqueries return zero rows); we distinguish that case with + // an EXISTS check. + var existing *float64 + var recipient bool + err := h.DB.Pool.QueryRow(ctx, + `SELECT + COALESCE( + (SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = $1 AND mt.addr = $2), + (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = $1 AND mat.addr = $2) + ), + EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = $1 AND mt.addr = $2) + OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = $1 AND mat.addr = $2)`, + msgID, identity, + ).Scan(&existing, &recipient) + if err != nil { + log.Printf("mark read %d: lookup: %v", msgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mark message read"}) + return + } + if !recipient { + c.JSON(http.StatusNotFound, gin.H{"error": "message not found"}) + return + } + if existing != nil { + // Already read; return the original timestamp unchanged. + c.JSON(http.StatusOK, gin.H{"id": msgID, "time_read": *existing}) + return + } + + now := float64(time.Now().UnixMicro()) / 1e6 + // Update whichever recipient row matches; only one of these will affect + // rows for any given (msg_id, addr) pair given the unique constraint on + // each table. + if _, err = h.DB.Pool.Exec(ctx, + `UPDATE msg_to SET time_read = $1 + WHERE msg_id = $2 AND addr = $3 AND time_read IS NULL`, + now, msgID, identity, + ); err != nil { + log.Printf("mark read %d: update msg_to: %v", msgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mark message read"}) + return + } + if _, err = h.DB.Pool.Exec(ctx, + `UPDATE msg_add_to SET time_read = $1 + WHERE msg_id = $2 AND addr = $3 AND time_read IS NULL`, + now, msgID, identity, + ); err != nil { + log.Printf("mark read %d: update msg_add_to: %v", msgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mark message read"}) + return + } + + c.JSON(http.StatusOK, gin.H{"id": msgID, "time_read": now}) +} + // addToInput is the JSON shape for the add-to request body. type addToInput struct { AddTo []string `json:"add_to"` diff --git a/src/main.go b/src/main.go index f45812b..60a4d49 100644 --- a/src/main.go +++ b/src/main.go @@ -104,6 +104,7 @@ func main() { fmsg.PUT("/:id", msgHandler.Update) fmsg.DELETE("/:id", msgHandler.Delete) fmsg.POST("/:id/send", msgHandler.Send) + fmsg.POST("/:id/read", msgHandler.MarkRead) fmsg.POST("/:id/add-to", msgHandler.AddRecipients) fmsg.GET("/:id/data", msgHandler.DownloadData) diff --git a/src/models/models.go b/src/models/models.go index cb8d8d5..0d93d19 100644 --- a/src/models/models.go +++ b/src/models/models.go @@ -24,5 +24,7 @@ type Message struct { Type string `json:"type"` Size int `json:"size"` ShortText string `json:"short_text,omitempty"` + Read bool `json:"read"` + TimeRead *float64 `json:"time_read"` Attachments []Attachment `json:"attachments"` }