b232b4b3d3
- Mentions now insert a span carrying the user id (handles names with spaces): the sanitizer permits <span class="mention" data-user-card="ULID">, comment and chat notifications resolve by id and only fire for users with access, and mentions render as clickable chips with the shared hover user-card. - Messages composer inserts an atomic, non-editable mention chip so the trailing space no longer collapses while typing. - Links inside chat messages and task comments are underlined/accented so they read as clickable. - Atomization: hold Ctrl/Cmd while dragging an effort slider to rebalance the sibling coefficients proportionally (sum stays ~1.00); added a hint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
522 lines
17 KiB
Go
522 lines
17 KiB
Go
package httpx
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
|
|
"bountyboard/internal/chat"
|
|
"bountyboard/internal/domain"
|
|
"bountyboard/internal/store"
|
|
"bountyboard/internal/ulid"
|
|
)
|
|
|
|
func (s *Server) routesBoard(mux *http.ServeMux) {
|
|
dev := func(h http.HandlerFunc) http.Handler { return s.authedRole("developer", h) }
|
|
// Developers and consultants can both read the board (consultants browse to
|
|
// open task detail; claim/start/etc. below stay developer-only).
|
|
mux.Handle("GET /api/v1/board", s.requireAuth(http.HandlerFunc(s.handleBoard)))
|
|
mux.Handle("GET /api/v1/my-tasks", s.requireAuth(s.requireRole("developer", http.HandlerFunc(s.handleMyTasks))))
|
|
mux.Handle("POST /api/v1/tasks/{id}/claim", dev(s.handleClaim))
|
|
mux.Handle("POST /api/v1/tasks/{id}/claim/withdraw", dev(s.handleWithdrawClaim))
|
|
mux.Handle("POST /api/v1/tasks/{id}/start", dev(s.handleStartTask))
|
|
mux.Handle("POST /api/v1/tasks/{id}/submit-review", dev(s.handleSubmitReview))
|
|
mux.Handle("POST /api/v1/tasks/{id}/abandon", dev(s.handleAbandonTask))
|
|
mux.Handle("POST /api/v1/tasks/{id}/comments", s.authed(s.handleAddComment))
|
|
mux.Handle("POST /api/v1/tasks/{id}/time", dev(s.handleLogTime))
|
|
mux.Handle("GET /api/v1/notifications", s.requireAuth(http.HandlerFunc(s.handleListNotifications)))
|
|
mux.Handle("POST /api/v1/notifications/read", s.authed(s.handleReadNotifications))
|
|
mux.Handle("GET /api/v1/users/{id}/card", s.requireAuth(http.HandlerFunc(s.handleUserCard)))
|
|
}
|
|
|
|
// boardCustomers resolves the developer's visibility scope: every customer
|
|
// that has at least one consultant who selected this developer into a pool.
|
|
// Visibility is customer-based, not task-owner-based — §4.4: all consultants
|
|
// assigned to a customer manage its tasks, so a task published by any of
|
|
// them belongs to the same board.
|
|
func (s *Server) boardCustomers(r *http.Request) ([]domain.Customer, error) {
|
|
me := CurrentUser(r.Context())
|
|
seen := map[string]bool{}
|
|
out := []domain.Customer{}
|
|
add := func(customers []domain.Customer) {
|
|
for _, c := range customers {
|
|
if !seen[c.ID] {
|
|
seen[c.ID] = true
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
}
|
|
// Developers: every customer that has a consultant who pooled them.
|
|
if me.Roles.Developer {
|
|
consultants, err := s.store.PoolConsultantIDs(r.Context(), me.ID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, cid := range consultants {
|
|
customers, err := s.store.ListCustomers(r.Context(), cid, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
add(customers)
|
|
}
|
|
}
|
|
// Consultants: every customer they are assigned to.
|
|
if me.Roles.Consultant {
|
|
customers, err := s.store.ListCustomers(r.Context(), me.ID, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
add(customers)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *Server) handleBoard(w http.ResponseWriter, r *http.Request) {
|
|
customers, err := s.boardCustomers(r)
|
|
if err != nil {
|
|
s.internalError(w, r, "board scope", err)
|
|
return
|
|
}
|
|
q := r.URL.Query()
|
|
customerIDs := []string{}
|
|
for _, c := range customers {
|
|
if want := q.Get("customerId"); want == "" || want == c.ID {
|
|
customerIDs = append(customerIDs, c.ID)
|
|
}
|
|
}
|
|
tasks := []domain.Task{}
|
|
if len(customerIDs) > 0 {
|
|
minBounty, _ := strconv.ParseFloat(q.Get("minBounty"), 64)
|
|
filter := store.TaskFilter{
|
|
CustomerIDs: customerIDs,
|
|
Statuses: []domain.TaskStatus{domain.StatusPublished, domain.StatusClaimRequested},
|
|
Search: strings.TrimSpace(q.Get("q")),
|
|
MinBounty: minBounty,
|
|
Sort: q.Get("sort"), // newest (default) | bounty
|
|
Limit: 200,
|
|
}
|
|
if tasks, err = s.store.ListTasks(r.Context(), filter); err != nil {
|
|
s.internalError(w, r, "list board", err)
|
|
return
|
|
}
|
|
}
|
|
|
|
// §10: optionally hide competing claim requests from other developers.
|
|
settings, err := s.store.GetSettings(r.Context())
|
|
hideCompeting := err == nil && settings.HideCompetingClaims
|
|
me := CurrentUser(r.Context()).ID
|
|
for i := range tasks {
|
|
mine := []domain.ClaimRequest{}
|
|
for _, cr := range tasks[i].ClaimRequests {
|
|
if cr.DeveloperID == me || !hideCompeting {
|
|
mine = append(mine, cr)
|
|
}
|
|
}
|
|
tasks[i].ClaimRequests = mine
|
|
}
|
|
|
|
// customers for the filter dropdown (full visibility scope)
|
|
customersOut := make([]map[string]any, len(customers))
|
|
for i, c := range customers {
|
|
customersOut[i] = map[string]any{"id": c.ID, "name": c.Name}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks, "customers": customersOut})
|
|
}
|
|
|
|
func (s *Server) handleMyTasks(w http.ResponseWriter, r *http.Request) {
|
|
me := CurrentUser(r.Context()).ID
|
|
tasks, err := s.store.ListTasks(r.Context(), store.TaskFilter{
|
|
AssigneeID: me,
|
|
Statuses: []domain.TaskStatus{
|
|
domain.StatusAssigned, domain.StatusInProgress, domain.StatusInReview,
|
|
domain.StatusChangesRequested, domain.StatusApproved,
|
|
},
|
|
Sort: "updated",
|
|
Limit: 200,
|
|
})
|
|
if err != nil {
|
|
s.internalError(w, r, "list my tasks", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"tasks": tasks})
|
|
}
|
|
|
|
// developerTask loads a task ensuring the developer can see it: the task's
|
|
// customer is in their pool-derived visibility scope, or they are the
|
|
// assignee (access survives mid-flight pool removal).
|
|
func (s *Server) developerTask(w http.ResponseWriter, r *http.Request, id string) (*domain.Task, bool) {
|
|
t, err := s.store.TaskByID(r.Context(), id)
|
|
if err != nil {
|
|
if errors.Is(err, store.ErrNotFound) {
|
|
writeError(w, http.StatusNotFound, "not_found", "task not found")
|
|
} else {
|
|
s.internalError(w, r, "load task", err)
|
|
}
|
|
return nil, false
|
|
}
|
|
if t.IsAssignedTo(CurrentUser(r.Context()).ID) {
|
|
return t, true
|
|
}
|
|
customers, err := s.boardCustomers(r)
|
|
if err != nil {
|
|
s.internalError(w, r, "board scope", err)
|
|
return nil, false
|
|
}
|
|
for _, c := range customers {
|
|
if c.ID == t.CustomerID {
|
|
return t, true
|
|
}
|
|
}
|
|
writeError(w, http.StatusForbidden, "forbidden", "this task is not on your board")
|
|
return nil, false
|
|
}
|
|
|
|
func (s *Server) handleClaim(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Note string `json:"note"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
t, ok := s.developerTask(w, r, r.PathValue("id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
me := CurrentUser(r.Context())
|
|
if t.HasClaimFrom(me.ID) {
|
|
writeError(w, http.StatusConflict, "already_requested", "you already requested this task")
|
|
return
|
|
}
|
|
claim := domain.ClaimRequest{DeveloperID: me.ID, Note: req.Note, At: time.Now().UTC()}
|
|
|
|
switch t.Status {
|
|
case domain.StatusPublished:
|
|
err := s.store.TransitionTask(r.Context(), t, domain.StatusClaimRequested,
|
|
me.ID, "claim_requested", map[string]any{"note": req.Note},
|
|
map[string]any{"claimRequests": []domain.ClaimRequest{claim}})
|
|
if err != nil {
|
|
s.taskTransitionError(w, err)
|
|
return
|
|
}
|
|
case domain.StatusClaimRequested:
|
|
// additional developers may pile on (visible per settings)
|
|
err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("tasks"), t.ID, t.Version, bson.M{
|
|
"$push": bson.M{
|
|
"claimRequests": claim,
|
|
"timeline": domain.TimelineEntry{
|
|
At: time.Now().UTC(), ActorID: me.ID, Event: "claim_requested",
|
|
Data: map[string]any{"note": req.Note},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
s.taskTransitionError(w, err)
|
|
return
|
|
}
|
|
default:
|
|
writeError(w, http.StatusConflict, "bad_status", "task is not open for claims")
|
|
return
|
|
}
|
|
s.notifyUser(r.Context(), t.ConsultantID, "claim_requested",
|
|
"Assignment requested: "+t.Title, me.Name+" wants this task.", "/tasks/"+t.ID)
|
|
s.Publish("board", "task.claimed", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleWithdrawClaim(w http.ResponseWriter, r *http.Request) {
|
|
t, ok := s.developerTask(w, r, r.PathValue("id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
me := CurrentUser(r.Context())
|
|
if !t.HasClaimFrom(me.ID) {
|
|
writeError(w, http.StatusConflict, "no_claim", "you have no claim on this task")
|
|
return
|
|
}
|
|
s.removeClaim(w, r, t, me.ID, me.ID, "claim_withdrawn")
|
|
}
|
|
|
|
// removeClaim drops one developer's claim; the task returns to published
|
|
// when no claims remain (§4.4).
|
|
func (s *Server) removeClaim(w http.ResponseWriter, r *http.Request, t *domain.Task,
|
|
developerID, actorID, event string) {
|
|
remaining := []domain.ClaimRequest{}
|
|
for _, cr := range t.ClaimRequests {
|
|
if cr.DeveloperID != developerID {
|
|
remaining = append(remaining, cr)
|
|
}
|
|
}
|
|
update := bson.M{
|
|
"$set": bson.M{"claimRequests": remaining},
|
|
"$push": bson.M{"timeline": domain.TimelineEntry{
|
|
At: time.Now().UTC(), ActorID: actorID, Event: event,
|
|
Data: map[string]any{"developerId": developerID},
|
|
}},
|
|
}
|
|
if len(remaining) == 0 && t.Status == domain.StatusClaimRequested {
|
|
if err := domain.Transition(t.Status, domain.StatusPublished); err != nil {
|
|
s.taskTransitionError(w, err)
|
|
return
|
|
}
|
|
update["$set"].(bson.M)["status"] = domain.StatusPublished
|
|
}
|
|
if err := store.UpdateVersioned(r.Context(), s.store.DB.Collection("tasks"), t.ID, t.Version, update); err != nil {
|
|
s.taskTransitionError(w, err)
|
|
return
|
|
}
|
|
s.Publish("board", "task.claim_removed", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleStartTask(w http.ResponseWriter, r *http.Request) {
|
|
t, ok := s.developerTask(w, r, r.PathValue("id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
me := CurrentUser(r.Context())
|
|
if !t.IsAssignedTo(me.ID) {
|
|
writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can start work")
|
|
return
|
|
}
|
|
// assigned → in_progress, and changes_requested → in_progress (resume)
|
|
if err := s.store.TransitionTask(r.Context(), t, domain.StatusInProgress,
|
|
me.ID, "work_started", nil, nil); err != nil {
|
|
s.taskTransitionError(w, err)
|
|
return
|
|
}
|
|
s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleSubmitReview(w http.ResponseWriter, r *http.Request) {
|
|
t, ok := s.developerTask(w, r, r.PathValue("id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
me := CurrentUser(r.Context())
|
|
if !t.IsAssignedTo(me.ID) {
|
|
writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can submit for review")
|
|
return
|
|
}
|
|
if err := s.store.TransitionTask(r.Context(), t, domain.StatusInReview,
|
|
me.ID, "submitted_for_review", nil, nil); err != nil {
|
|
s.taskTransitionError(w, err)
|
|
return
|
|
}
|
|
s.notifyUser(r.Context(), t.ConsultantID, "review_requested",
|
|
"Ready for review: "+t.Title, me.Name+" submitted this task.", "/tasks/"+t.ID)
|
|
s.Publish("board", "task.updated", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleAbandonTask(w http.ResponseWriter, r *http.Request) {
|
|
t, ok := s.developerTask(w, r, r.PathValue("id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
me := CurrentUser(r.Context())
|
|
if !t.IsAssignedTo(me.ID) {
|
|
writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can abandon the task")
|
|
return
|
|
}
|
|
if err := s.store.TransitionTask(r.Context(), t, domain.StatusPublished,
|
|
me.ID, "abandoned", nil, map[string]any{"assignee": nil}); err != nil {
|
|
s.taskTransitionError(w, err)
|
|
return
|
|
}
|
|
s.notifyUser(r.Context(), t.ConsultantID, "task_abandoned",
|
|
"Task abandoned: "+t.Title, me.Name+" returned this task to the board.", "/tasks/"+t.ID)
|
|
s.Publish("board", "task.published", map[string]any{"taskId": t.ID, "customerId": t.CustomerID})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// mentionUIDRe pulls user ids out of mention spans the sanitizer emitted:
|
|
// <span class="mention" data-user-card="ULID">@Name</span>.
|
|
var mentionUIDRe = regexp.MustCompile(`data-user-card="([0-9A-Za-z]+)"`)
|
|
|
|
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Body string `json:"body"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
body := chat.SanitizeHTML(req.Body)
|
|
if strings.TrimSpace(chat.SanitizePlain(body)) == "" {
|
|
writeError(w, http.StatusBadRequest, "empty_comment", "comment cannot be empty")
|
|
return
|
|
}
|
|
me := CurrentUser(r.Context())
|
|
t, err := s.store.TaskByID(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "task not found")
|
|
return
|
|
}
|
|
// commenters: consultants of the customer, the assignee, claimers
|
|
canComment := me.Roles.Admin || t.IsAssignedTo(me.ID) || t.HasClaimFrom(me.ID)
|
|
if !canComment && me.Roles.Consultant {
|
|
if c, err := s.store.CustomerByID(r.Context(), t.CustomerID); err == nil && c.HasConsultant(me.ID) {
|
|
canComment = true
|
|
}
|
|
}
|
|
if !canComment {
|
|
writeError(w, http.StatusForbidden, "forbidden", "no access to comment on this task")
|
|
return
|
|
}
|
|
|
|
comment := domain.Comment{ID: ulid.New(), AuthorID: me.ID, Body: body, At: time.Now().UTC()}
|
|
_, err = s.store.DB.Collection("tasks").UpdateOne(r.Context(), bson.M{"_id": t.ID}, bson.M{
|
|
"$push": bson.M{"comments": comment},
|
|
"$set": bson.M{"updatedAt": time.Now().UTC()},
|
|
"$inc": bson.M{"version": 1},
|
|
})
|
|
if err != nil {
|
|
s.internalError(w, r, "add comment", err)
|
|
return
|
|
}
|
|
|
|
s.notifyMentions(r, t, me, body)
|
|
// always tell the "other side"
|
|
if t.IsAssignedTo(me.ID) || t.HasClaimFrom(me.ID) {
|
|
s.notifyUser(r.Context(), t.ConsultantID, "comment",
|
|
"New comment on "+t.Title, me.Name+" commented.", "/tasks/"+t.ID)
|
|
} else if t.Assignee != nil && t.Assignee.Kind == "human" && t.Assignee.UserID != me.ID {
|
|
s.notifyUser(r.Context(), t.Assignee.UserID, "comment",
|
|
"New comment on "+t.Title, me.Name+" commented.", "/tasks/"+t.ID)
|
|
}
|
|
s.Publish("board", "task.comment", map[string]any{"taskId": t.ID})
|
|
writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
|
|
}
|
|
|
|
// notifyMentions notifies users referenced by mention spans in the comment
|
|
// body, but only those who actually have access to the task (§11.3).
|
|
func (s *Server) notifyMentions(r *http.Request, t *domain.Task, author *domain.User, body string) {
|
|
matches := mentionUIDRe.FindAllStringSubmatch(body, 20)
|
|
if len(matches) == 0 {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
// who can see this task: its consultants, assignee, claimers, commenters
|
|
access := map[string]bool{t.ConsultantID: true}
|
|
if c, err := s.store.CustomerByID(ctx, t.CustomerID); err == nil {
|
|
for _, id := range c.ConsultantIDs {
|
|
access[id] = true
|
|
}
|
|
}
|
|
if t.Assignee != nil && t.Assignee.UserID != "" {
|
|
access[t.Assignee.UserID] = true
|
|
}
|
|
for _, cr := range t.ClaimRequests {
|
|
access[cr.DeveloperID] = true
|
|
}
|
|
for _, c := range t.Comments {
|
|
access[c.AuthorID] = true
|
|
}
|
|
notified := map[string]bool{}
|
|
for _, m := range matches {
|
|
uid := m[1]
|
|
if uid == author.ID || notified[uid] || !access[uid] {
|
|
continue
|
|
}
|
|
notified[uid] = true
|
|
s.notifyUser(ctx, uid, "mention",
|
|
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
|
|
}
|
|
}
|
|
|
|
func (s *Server) handleLogTime(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Minutes int `json:"minutes"`
|
|
Note string `json:"note"`
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
if req.Minutes <= 0 || req.Minutes > 24*60 {
|
|
writeError(w, http.StatusBadRequest, "bad_minutes", "minutes must be 1..1440")
|
|
return
|
|
}
|
|
t, ok := s.developerTask(w, r, r.PathValue("id"))
|
|
if !ok {
|
|
return
|
|
}
|
|
me := CurrentUser(r.Context())
|
|
if !t.IsAssignedTo(me.ID) {
|
|
writeError(w, http.StatusForbidden, "not_assignee", "only the assignee can log time")
|
|
return
|
|
}
|
|
entry := domain.TimeLogEntry{DeveloperID: me.ID, Minutes: req.Minutes,
|
|
Note: req.Note, At: time.Now().UTC()}
|
|
_, err := s.store.DB.Collection("tasks").UpdateOne(r.Context(), bson.M{"_id": t.ID}, bson.M{
|
|
"$push": bson.M{"timeLog": entry},
|
|
"$set": bson.M{"updatedAt": time.Now().UTC()},
|
|
"$inc": bson.M{"version": 1},
|
|
})
|
|
if err != nil {
|
|
s.internalError(w, r, "log time", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, map[string]any{"entry": entry})
|
|
}
|
|
|
|
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
|
|
q := r.URL.Query()
|
|
me := CurrentUser(r.Context()).ID
|
|
list, err := s.store.ListNotifications(r.Context(), me,
|
|
q.Get("unread") == "true", q.Get("cursor"), atoiDefault(q.Get("limit"), 30))
|
|
if err != nil {
|
|
s.internalError(w, r, "list notifications", err)
|
|
return
|
|
}
|
|
unread, err := s.store.CountUnreadNotifications(r.Context(), me)
|
|
if err != nil {
|
|
s.internalError(w, r, "count unread", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"notifications": list, "unread": unread})
|
|
}
|
|
|
|
func (s *Server) handleReadNotifications(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
IDs []string `json:"ids"` // empty = mark all read
|
|
}
|
|
if !decodeJSON(w, r, &req) {
|
|
return
|
|
}
|
|
n, err := s.store.MarkNotificationsRead(r.Context(), CurrentUser(r.Context()).ID, req.IDs)
|
|
if err != nil {
|
|
s.internalError(w, r, "mark read", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"marked": n})
|
|
}
|
|
|
|
// handleUserCard serves the public profile hover card (§11.13).
|
|
func (s *Server) handleUserCard(w http.ResponseWriter, r *http.Request) {
|
|
u, err := s.store.UserByID(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "user not found")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"card": map[string]any{
|
|
"id": u.ID, "name": u.Name, "bio": u.Bio, "roles": u.Roles,
|
|
"avatarFileId": u.AvatarFileID, "contact": u.Contact, "extra": u.Extra,
|
|
}})
|
|
}
|
|
|
|
// notifyUser inserts an in-app notification and pushes it live.
|
|
func (s *Server) notifyUser(ctx context.Context, userID, kind, title, body, link string) {
|
|
n := &store.Notification{UserID: userID, Kind: kind, Title: title, Body: body, Link: link}
|
|
if err := s.store.InsertNotification(ctx, n); err != nil {
|
|
s.log.Warn("insert notification", "err", err)
|
|
return
|
|
}
|
|
s.Publish("notifications", "notification", n)
|
|
}
|