package store import ( "context" "errors" "fmt" "time" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" "bountyboard/internal/domain" "bountyboard/internal/ulid" ) func (s *Store) tasks() *mongo.Collection { return s.DB.Collection("tasks") } func (s *Store) InsertTask(ctx context.Context, t *domain.Task) error { now := time.Now().UTC() if t.ID == "" { t.ID = ulid.New() } if t.RootID == "" { t.RootID = t.ID } t.CreatedAt, t.UpdatedAt, t.Version = now, now, 1 if t.AcceptanceCriteria == nil { t.AcceptanceCriteria = []string{} } if t.Attachments == nil { t.Attachments = []domain.TaskAttachment{} } if t.Links == nil { t.Links = []string{} } if t.ClaimRequests == nil { t.ClaimRequests = []domain.ClaimRequest{} } if t.Timeline == nil { t.Timeline = []domain.TimelineEntry{} } if t.Comments == nil { t.Comments = []domain.Comment{} } if t.TimeLog == nil { t.TimeLog = []domain.TimeLogEntry{} } if _, err := s.tasks().InsertOne(ctx, t); err != nil { if mongo.IsDuplicateKeyError(err) { return fmt.Errorf("task %s: %w", t.ID, ErrDuplicate) } return fmt.Errorf("insert task: %w", err) } return nil } func (s *Store) TaskByID(ctx context.Context, id string) (*domain.Task, error) { var t domain.Task err := s.tasks().FindOne(ctx, bson.M{"_id": id}).Decode(&t) if errors.Is(err, mongo.ErrNoDocuments) { return nil, ErrNotFound } if err != nil { return nil, fmt.Errorf("find task: %w", err) } return &t, nil } // TaskFilter selects tasks for boards and workers. type TaskFilter struct { CustomerID string CustomerIDs []string ConsultantID string ConsultantIDs []string Statuses []domain.TaskStatus AssigneeID string RootID string ParentID string Search string // Mongo text index MinBounty float64 Sort string // newest | bounty | updated Cursor string Limit int } func (f TaskFilter) build() bson.M { q := bson.M{} if f.CustomerID != "" { q["customerId"] = f.CustomerID } if len(f.CustomerIDs) > 0 { q["customerId"] = bson.M{"$in": f.CustomerIDs} } if f.ConsultantID != "" { q["consultantId"] = f.ConsultantID } if len(f.ConsultantIDs) > 0 { q["consultantId"] = bson.M{"$in": f.ConsultantIDs} } if len(f.Statuses) > 0 { q["status"] = bson.M{"$in": f.Statuses} } if f.AssigneeID != "" { q["assignee.userId"] = f.AssigneeID } if f.RootID != "" { q["rootId"] = f.RootID } if f.ParentID != "" { q["parentId"] = f.ParentID } if f.Search != "" { q["$text"] = bson.M{"$search": f.Search} } if f.MinBounty > 0 { q["bounty"] = bson.M{"$gte": f.MinBounty} } return q } func (s *Store) ListTasks(ctx context.Context, f TaskFilter) ([]domain.Task, error) { q := f.build() limit := f.Limit if limit <= 0 || limit > 500 { limit = 100 } sort := bson.D{{Key: "_id", Value: -1}} // newest first (ULID) switch f.Sort { case "bounty": sort = bson.D{{Key: "bounty", Value: -1}, {Key: "_id", Value: -1}} case "updated": sort = bson.D{{Key: "updatedAt", Value: -1}} } if f.Cursor != "" && f.Sort != "bounty" && f.Sort != "updated" { q["_id"] = bson.M{"$lt": f.Cursor} } cur, err := s.tasks().Find(ctx, q, options.Find().SetSort(sort).SetLimit(int64(limit))) if err != nil { return nil, fmt.Errorf("list tasks: %w", err) } out := []domain.Task{} if err := cur.All(ctx, &out); err != nil { return nil, fmt.Errorf("decode tasks: %w", err) } return out, nil } // UpsertResult describes what an imported-ticket upsert did. type UpsertResult struct { Created bool UpstreamChanged bool // changed after atomization began (timeline-only) Task *domain.Task } // UpsertImportedTask implements the §5.3 idempotent upsert keyed on // (external.system, external.key, customerId). While the task is still // `imported`, upstream refreshes title/description/attachments/raw; after // atomization begins, a change only appends an "upstream ticket changed" // timeline entry (caller notifies the consultant). func (s *Store) UpsertImportedTask(ctx context.Context, incoming *domain.Task) (*UpsertResult, error) { if incoming.External == nil { return nil, errors.New("imported task requires external ref") } key := bson.M{ "external.system": incoming.External.System, "external.key": incoming.External.Key, "customerId": incoming.CustomerID, } var existing domain.Task err := s.tasks().FindOne(ctx, key).Decode(&existing) switch { case errors.Is(err, mongo.ErrNoDocuments): incoming.Origin = domain.OriginImported incoming.Status = domain.StatusImported incoming.Timeline = []domain.TimelineEntry{{ At: time.Now().UTC(), ActorID: "system", Event: "imported", Data: map[string]any{"key": incoming.External.Key}, }} if err := s.InsertTask(ctx, incoming); err != nil { // A concurrent worker may have inserted the same key (unique // index): treat as found-and-unchanged. if errors.Is(err, ErrDuplicate) { return &UpsertResult{Created: false, Task: incoming}, nil } return nil, err } return &UpsertResult{Created: true, Task: incoming}, nil case err != nil: return nil, fmt.Errorf("find imported task: %w", err) } if existing.External.ContentHash == incoming.External.ContentHash { return &UpsertResult{Task: &existing}, nil // nothing new upstream } now := time.Now().UTC() if existing.Status == domain.StatusImported { _, err := s.tasks().UpdateOne(ctx, bson.M{"_id": existing.ID}, bson.M{ "$set": bson.M{ "title": incoming.Title, "description": incoming.Description, "attachments": incoming.Attachments, "external.raw": incoming.External.Raw, "external.url": incoming.External.URL, "external.type": incoming.External.Type, "external.contentHash": incoming.External.ContentHash, "updatedAt": now, }, "$inc": bson.M{"version": 1}, }) if err != nil { return nil, fmt.Errorf("refresh imported task: %w", err) } return &UpsertResult{Task: &existing}, nil } // Atomization already began: record only. _, err = s.tasks().UpdateOne(ctx, bson.M{"_id": existing.ID}, bson.M{ "$set": bson.M{"external.contentHash": incoming.External.ContentHash, "updatedAt": now}, "$push": bson.M{"timeline": domain.TimelineEntry{ At: now, ActorID: "system", Event: "upstream_changed", Data: map[string]any{"key": incoming.External.Key}, }}, "$inc": bson.M{"version": 1}, }) if err != nil { return nil, fmt.Errorf("record upstream change: %w", err) } return &UpsertResult{UpstreamChanged: true, Task: &existing}, nil } // MarkTaskOrphaned flags a task whose upstream ticket disappeared (§5.3). func (s *Store) MarkTaskOrphaned(ctx context.Context, id string) error { now := time.Now().UTC() res, err := s.tasks().UpdateOne(ctx, bson.M{"_id": id, "external.orphaned": bson.M{"$ne": true}}, bson.M{ "$set": bson.M{"external.orphaned": true, "updatedAt": now}, "$push": bson.M{"timeline": domain.TimelineEntry{ At: now, ActorID: "system", Event: "orphaned", Data: map[string]any{"reason": "no longer returned by upstream query"}, }}, "$inc": bson.M{"version": 1}, }) if err != nil { return fmt.Errorf("mark orphaned: %w", err) } if res.MatchedCount == 0 { return ErrNotFound // already orphaned or missing } return nil } // AppendTimeline pushes a timeline entry without status change. func (s *Store) AppendTimeline(ctx context.Context, taskID string, e domain.TimelineEntry) error { if e.At.IsZero() { e.At = time.Now().UTC() } _, err := s.tasks().UpdateOne(ctx, bson.M{"_id": taskID}, bson.M{ "$push": bson.M{"timeline": e}, "$set": bson.M{"updatedAt": time.Now().UTC()}, "$inc": bson.M{"version": 1}, }) if err != nil { return fmt.Errorf("append timeline: %w", err) } return nil } // TransitionTask performs a version-checked §4.4 transition with a timeline // entry and optional extra $set fields. func (s *Store) TransitionTask(ctx context.Context, t *domain.Task, to domain.TaskStatus, actorID, event string, data map[string]any, extraSet bson.M) error { if err := domain.Transition(t.Status, to); err != nil { return err } set := bson.M{"status": to} for k, v := range extraSet { set[k] = v } update := bson.M{ "$set": set, "$push": bson.M{"timeline": domain.TimelineEntry{ At: time.Now().UTC(), ActorID: actorID, Event: event, Data: data, }}, } return UpdateVersioned(ctx, s.tasks(), t.ID, t.Version, update) } // SetTaskAttachments persists attachment metadata (e.g. cached fileIds). func (s *Store) SetTaskAttachments(ctx context.Context, taskID string, atts []domain.TaskAttachment) error { _, err := s.tasks().UpdateOne(ctx, bson.M{"_id": taskID}, bson.M{ "$set": bson.M{"attachments": atts, "updatedAt": time.Now().UTC()}, "$inc": bson.M{"version": 1}, }) if err != nil { return fmt.Errorf("set attachments: %w", err) } return nil } // ListImportedTasksForOrphanCheck returns imported-origin tasks for one // customer/system/consultant that are still live (not archived). func (s *Store) ListImportedTasksForOrphanCheck(ctx context.Context, customerID, system, consultantID string) ([]domain.Task, error) { cur, err := s.tasks().Find(ctx, bson.M{ "customerId": customerID, "consultantId": consultantID, "origin": domain.OriginImported, "external.system": system, "status": bson.M{"$ne": domain.StatusArchived}, }) if err != nil { return nil, fmt.Errorf("list for orphan check: %w", err) } out := []domain.Task{} if err := cur.All(ctx, &out); err != nil { return nil, fmt.Errorf("decode orphan check: %w", err) } return out, nil } // DeleteTasks removes tasks matching ids (re-run subdivision cleanup). func (s *Store) DeleteTasks(ctx context.Context, ids []string) (int64, error) { res, err := s.tasks().DeleteMany(ctx, bson.M{"_id": bson.M{"$in": ids}}) if err != nil { return 0, fmt.Errorf("delete tasks: %w", err) } return res.DeletedCount, nil }