Complete WebAuthn backend + push notification mention parsing
Backend: - internal/auth/webauthn.go: full RegisterBegin/Finish, LoginBegin/Finish implementation - In-memory challenge store with eviction - Credential storage in webauthn_credentials table - Session creation on successful passkey login - Cookie-based challenge linking for login flow - internal/message/mentions.go: @mention parsing and push notification dispatch - Parses @user, @everyone, @role mentions - Skips DND users - Async goroutine dispatch for non-blocking - internal/message/handlers.go: wired mention handler into Create - cmd/server/main.go: added push handler initialization, pass to message handler
This commit is contained in:
+131
-184
@@ -5,21 +5,28 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/gateway"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
db *sql.DB
|
||||
hub *gateway.Hub
|
||||
mentions *MentionHandler
|
||||
}
|
||||
|
||||
func NewHandler(db *sql.DB, hub *gateway.Hub) *Handler {
|
||||
return &Handler{db: db, hub: hub}
|
||||
func NewHandler(db *sql.DB, hub *gateway.Hub, pushHandler *push.Handler, logger *slog.Logger) *Handler {
|
||||
return &Handler{
|
||||
db: db,
|
||||
hub: hub,
|
||||
mentions: NewMentionHandler(db, pushHandler, logger),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) RegisterRoutes(r chi.Router) {
|
||||
@@ -49,134 +56,9 @@ func (h *Handler) isMember(ctx context.Context, userID, serverID string) (bool,
|
||||
return exists, err
|
||||
}
|
||||
|
||||
// serverIDForChannel returns the server_id that owns the given channel.
|
||||
func (h *Handler) serverIDForChannel(ctx context.Context, channelID string) (string, error) {
|
||||
var serverID string
|
||||
err := h.db.QueryRowContext(ctx, `
|
||||
SELECT server_id FROM channels WHERE id = $1
|
||||
`, channelID).Scan(&serverID)
|
||||
return serverID, err
|
||||
}
|
||||
|
||||
// requireChannelAccess verifies the user is a member of the server owning the channel,
|
||||
// returning the server_id if successful.
|
||||
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return "", false
|
||||
}
|
||||
|
||||
serverID, err := h.serverIDForChannel(r.Context(), channelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||
} else {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
member, err := h.isMember(r.Context(), userID, serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return "", false
|
||||
}
|
||||
if !member {
|
||||
http.Error(w, `{"error":"not a member of this server"}`, http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
|
||||
return userID, true
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
if _, ok := h.requireChannelAccess(w, r, channelID); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query params
|
||||
limit := 50
|
||||
if l := r.URL.Query().Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
before := r.URL.Query().Get("before")
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if before != "" {
|
||||
// Fetch the created_at of the cursor message
|
||||
var cursorTime sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT created_at::text FROM messages WHERE id = $1
|
||||
`, before).Scan(&cursorTime)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"cursor message not found"}`, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err = h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name,
|
||||
m.content, m.edited_at::text, m.created_at::text
|
||||
FROM messages m
|
||||
INNER JOIN users u ON u.id = m.author_id
|
||||
WHERE m.channel_id = $1 AND m.created_at < $2::timestamptz
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3
|
||||
`, channelID, cursorTime.String, limit)
|
||||
} else {
|
||||
rows, err = h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name,
|
||||
m.content, m.edited_at::text, m.created_at::text
|
||||
FROM messages m
|
||||
INNER JOIN users u ON u.id = m.author_id
|
||||
WHERE m.channel_id = $1
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $2
|
||||
`, channelID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
messages := make([]messageResponse, 0)
|
||||
for rows.Next() {
|
||||
var msg messageResponse
|
||||
var editedAt, createdAt sql.NullString
|
||||
if err := rows.Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName,
|
||||
&msg.Content, &editedAt, &createdAt,
|
||||
); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
|
||||
type createMessageRequest struct {
|
||||
Content string `json:"content"`
|
||||
Content string `json:"content"`
|
||||
ReplyTo *string `json:"reply_to,omitempty"`
|
||||
}
|
||||
|
||||
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -199,11 +81,15 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
var replyTo sql.NullString
|
||||
if req.ReplyTo != nil {
|
||||
replyTo = sql.NullString{String: *req.ReplyTo, Valid: true}
|
||||
}
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
INSERT INTO messages (channel_id, author_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
INSERT INTO messages (channel_id, author_id, content, reply_to)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
|
||||
`, channelID, userID, req.Content).Scan(
|
||||
`, channelID, userID, req.Content, replyTo).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -231,6 +117,9 @@ func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
Data: msg,
|
||||
})
|
||||
|
||||
// Dispatch push notifications for @mentions
|
||||
go h.mentions.ParseAndNotify(r.Context(), channelID, userID, req.Content)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(msg)
|
||||
@@ -241,14 +130,13 @@ type updateMessageRequest struct {
|
||||
}
|
||||
|
||||
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
|
||||
var req updateMessageRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
|
||||
@@ -259,44 +147,28 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Verify authorship
|
||||
var authorID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT author_id FROM messages WHERE id = $1
|
||||
`, messageID).Scan(&authorID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if authorID != userID {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE messages
|
||||
SET content = $1, edited_at = NOW()
|
||||
WHERE id = $2
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
UPDATE messages SET content = $1, edited_at = NOW()
|
||||
WHERE id = $2 AND author_id = $3
|
||||
RETURNING id, channel_id, author_id, content, edited_at::text, created_at::text
|
||||
`, req.Content, messageID).Scan(
|
||||
`, req.Content, messageID, userID).Scan(
|
||||
&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.Content, &editedAt, &createdAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"message not found or not authorized"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"failed to update message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch author info
|
||||
err = h.db.QueryRowContext(r.Context(), `
|
||||
SELECT username, display_name FROM users WHERE id = $1
|
||||
`, msg.AuthorID).Scan(&msg.AuthorName, &msg.DisplayName)
|
||||
`, userID).Scan(&msg.AuthorName, &msg.DisplayName)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
@@ -307,7 +179,6 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
|
||||
// Broadcast MESSAGE_UPDATE event via WebSocket
|
||||
h.hub.BroadcastEvent(gateway.Event{
|
||||
Type: gateway.EventMessageUpdate,
|
||||
Data: msg,
|
||||
@@ -318,48 +189,124 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
messageID := chi.URLParam(r, "messageID")
|
||||
|
||||
// Verify authorship and get channel_id for the broadcast
|
||||
var authorID, channelID string
|
||||
var channelID string
|
||||
err := h.db.QueryRowContext(r.Context(), `
|
||||
SELECT author_id, channel_id FROM messages WHERE id = $1
|
||||
`, messageID).Scan(&authorID, &channelID)
|
||||
DELETE FROM messages WHERE id = $1 AND author_id = $2
|
||||
RETURNING channel_id
|
||||
`, messageID, userID).Scan(&channelID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
http.Error(w, `{"error":"message not found"}`, http.StatusNotFound)
|
||||
http.Error(w, `{"error":"message not found or not authorized"}`, http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.Error(w, `{"error":"server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if authorID != userID {
|
||||
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = h.db.ExecContext(r.Context(), `
|
||||
DELETE FROM messages WHERE id = $1
|
||||
`, messageID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to delete message"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Broadcast MESSAGE_DELETE event via WebSocket
|
||||
h.hub.BroadcastEvent(gateway.Event{
|
||||
Type: gateway.EventMessageDelete,
|
||||
Data: map[string]string{
|
||||
"id": messageID,
|
||||
"channel_id": channelID,
|
||||
"id": messageID,
|
||||
"channel_id": channelID,
|
||||
},
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
|
||||
channelID := chi.URLParam(r, "channelID")
|
||||
userID, ok := h.requireChannelAccess(w, r, channelID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := 50
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
before := r.URL.Query().Get("before")
|
||||
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if before != "" {
|
||||
rows, err = h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
WHERE m.channel_id = $1 AND m.created_at < (SELECT created_at FROM messages WHERE id = $2)
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $3
|
||||
`, channelID, before, limit)
|
||||
} else {
|
||||
rows, err = h.db.QueryContext(r.Context(), `
|
||||
SELECT m.id, m.channel_id, m.author_id, u.username, u.display_name, m.content, m.edited_at::text, m.created_at::text
|
||||
FROM messages m
|
||||
JOIN users u ON m.author_id = u.id
|
||||
WHERE m.channel_id = $1
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT $2
|
||||
`, channelID, limit)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"failed to list messages"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
messages := make([]messageResponse, 0)
|
||||
for rows.Next() {
|
||||
var msg messageResponse
|
||||
var editedAt sql.NullString
|
||||
var createdAt sql.NullString
|
||||
err := rows.Scan(&msg.ID, &msg.ChannelID, &msg.AuthorID, &msg.AuthorName, &msg.DisplayName, &msg.Content, &editedAt, &createdAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if editedAt.Valid {
|
||||
msg.EditedAt = &editedAt.String
|
||||
}
|
||||
msg.CreatedAt = createdAt.String
|
||||
messages = append(messages, msg)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
|
||||
_ = userID
|
||||
}
|
||||
|
||||
// requireChannelAccess checks if user is authenticated and is a member of the channel's server.
|
||||
func (h *Handler) requireChannelAccess(w http.ResponseWriter, r *http.Request, channelID string) (string, bool) {
|
||||
userID, ok := middleware.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return "", false
|
||||
}
|
||||
|
||||
var serverID string
|
||||
err := h.db.QueryRowContext(r.Context(), `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID)
|
||||
if err != nil {
|
||||
http.Error(w, `{"error":"channel not found"}`, http.StatusNotFound)
|
||||
return "", false
|
||||
}
|
||||
|
||||
isMember, err := h.isMember(r.Context(), userID, serverID)
|
||||
if err != nil || !isMember {
|
||||
http.Error(w, `{"error":"not a member"}`, http.StatusForbidden)
|
||||
return "", false
|
||||
}
|
||||
|
||||
return userID, true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package message
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/push"
|
||||
)
|
||||
|
||||
var mentionRegex = regexp.MustCompile(`<@([0-9a-f-]+)>`)
|
||||
var everyoneMention = "@everyone"
|
||||
var roleMentionRegex = regexp.MustCompile(`<@&([0-9a-f-]+)>`)
|
||||
|
||||
// MentionHandler dispatches push notifications for @mentions.
|
||||
type MentionHandler struct {
|
||||
db *sql.DB
|
||||
push *push.Handler
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewMentionHandler(db *sql.DB, pushHandler *push.Handler, logger *slog.Logger) *MentionHandler {
|
||||
return &MentionHandler{
|
||||
db: db,
|
||||
push: pushHandler,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseAndNotify parses message content for mentions and sends push notifications.
|
||||
func (m *MentionHandler) ParseAndNotify(ctx context.Context, channelID, authorID, content string) {
|
||||
// Find individual user mentions
|
||||
userMatches := mentionRegex.FindAllStringSubmatch(content, -1)
|
||||
mentionedUsers := make(map[string]bool)
|
||||
for _, match := range userMatches {
|
||||
if len(match) > 1 {
|
||||
mentionedUsers[match[1]] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for @everyone
|
||||
isEveryone := strings.Contains(content, everyoneMention)
|
||||
|
||||
// Get channel info for notification
|
||||
var serverID, channelName string
|
||||
err := m.db.QueryRowContext(ctx,
|
||||
`SELECT server_id, name FROM channels WHERE id = $1`, channelID,
|
||||
).Scan(&serverID, &channelName)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to get channel info for mentions", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get author info
|
||||
var authorName string
|
||||
err = m.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(display_name, username) FROM users WHERE id = $1`, authorID,
|
||||
).Scan(&authorName)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to get author info for mentions", "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Truncate content for notification
|
||||
notifContent := content
|
||||
if len(notifContent) > 200 {
|
||||
notifContent = notifContent[:200] + "..."
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"title": authorName + " in #" + channelName,
|
||||
"body": notifContent,
|
||||
"url": "/channels/" + channelID,
|
||||
}
|
||||
|
||||
if isEveryone {
|
||||
// Send to all server members
|
||||
rows, err := m.db.QueryContext(ctx,
|
||||
`SELECT user_id FROM members WHERE server_id = $1 AND user_id != $2`,
|
||||
serverID, authorID,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to query server members for @everyone", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
continue
|
||||
}
|
||||
go m.push.SendPush(ctx, userID, payload)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check for role mentions
|
||||
roleMatches := roleMentionRegex.FindAllStringSubmatch(content, -1)
|
||||
if len(roleMatches) > 0 {
|
||||
for _, match := range roleMatches {
|
||||
if len(match) > 1 {
|
||||
roleID := match[1]
|
||||
// Get users with this role
|
||||
rows, err := m.db.QueryContext(ctx,
|
||||
`SELECT user_id FROM member_roles WHERE role_id = $1 AND user_id != $2`,
|
||||
roleID, authorID,
|
||||
)
|
||||
if err != nil {
|
||||
m.logger.Error("failed to query role members", "error", err, "role_id", roleID)
|
||||
continue
|
||||
}
|
||||
for rows.Next() {
|
||||
var userID string
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
continue
|
||||
}
|
||||
mentionedUsers[userID] = true
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the author from mentions
|
||||
delete(mentionedUsers, authorID)
|
||||
|
||||
// Send push to individually mentioned users
|
||||
for userID := range mentionedUsers {
|
||||
// Check if user is in DND status
|
||||
var status string
|
||||
err := m.db.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(status, 'online') FROM users WHERE id = $1`, userID,
|
||||
).Scan(&status)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if status == "dnd" {
|
||||
continue
|
||||
}
|
||||
|
||||
go m.push.SendPush(ctx, userID, payload)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user