Files
BountyBoard/internal/store/notifications.go
etalon 4e01b64bbd feat: ticketing sync workers with idempotent import and orphan flagging (phase 6)
- task domain model + exhaustive §4.4 status machine tests (single source of truth)
- per-customer polling workers reconciled every 30s from the customers
  collection; panic-safe runs; manual sync-now trigger; status provider
  for the admin panel
- §5.3 upsert keyed on (system, key, customerId): refreshes content while
  status=imported, records upstream_changed timeline + notification after
- attachments cached into GridFS via connector-authorized downloads
- orphaned tickets flagged with timeline + consultant notification, never
  deleted; idempotent across polls
- consultant identity from users.extra.ticketingIdentities per system
- notifications store (insert/list/unread-count/mark-read)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 19:03:13 +02:00

80 lines
2.3 KiB
Go

package store
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo/options"
"bountyboard/internal/ulid"
)
// Notification is an in-app notification (§4.8).
type Notification struct {
ID string `bson:"_id" json:"id"`
UserID string `bson:"userId" json:"userId"`
Kind string `bson:"kind" json:"kind"`
Title string `bson:"title" json:"title"`
Body string `bson:"body" json:"body"`
Link string `bson:"link" json:"link"`
ReadAt *time.Time `bson:"readAt" json:"readAt"`
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
}
func (s *Store) InsertNotification(ctx context.Context, n *Notification) error {
n.ID = ulid.New()
n.CreatedAt = time.Now().UTC()
if _, err := s.DB.Collection("notifications").InsertOne(ctx, n); err != nil {
return fmt.Errorf("insert notification: %w", err)
}
return nil
}
func (s *Store) ListNotifications(ctx context.Context, userID string, unreadOnly bool, cursor string, limit int) ([]Notification, error) {
if limit <= 0 || limit > 100 {
limit = 30
}
q := bson.M{"userId": userID}
if unreadOnly {
q["readAt"] = nil
}
if cursor != "" {
q["_id"] = bson.M{"$lt": cursor}
}
cur, err := s.DB.Collection("notifications").Find(ctx, q,
options.Find().SetSort(bson.D{{Key: "_id", Value: -1}}).SetLimit(int64(limit)))
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
out := []Notification{}
if err := cur.All(ctx, &out); err != nil {
return nil, fmt.Errorf("decode notifications: %w", err)
}
return out, nil
}
func (s *Store) CountUnreadNotifications(ctx context.Context, userID string) (int64, error) {
n, err := s.DB.Collection("notifications").CountDocuments(ctx,
bson.M{"userId": userID, "readAt": nil})
if err != nil {
return 0, fmt.Errorf("count unread: %w", err)
}
return n, nil
}
// MarkNotificationsRead marks the given ids (or all when ids is empty) read.
func (s *Store) MarkNotificationsRead(ctx context.Context, userID string, ids []string) (int64, error) {
q := bson.M{"userId": userID, "readAt": nil}
if len(ids) > 0 {
q["_id"] = bson.M{"$in": ids}
}
res, err := s.DB.Collection("notifications").UpdateMany(ctx, q,
bson.M{"$set": bson.M{"readAt": time.Now().UTC()}})
if err != nil {
return 0, fmt.Errorf("mark read: %w", err)
}
return res.ModifiedCount, nil
}