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>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user