4e01b64bbd
- 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>
80 lines
2.3 KiB
Go
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
|
|
}
|