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,108 @@
|
||||
package domain
|
||||
|
||||
import "fmt"
|
||||
|
||||
// TaskStatus and its transition rules are the single source of truth for the
|
||||
// §4.4 status machine. Every handler and worker validates transitions here —
|
||||
// never inline.
|
||||
type TaskStatus string
|
||||
|
||||
const (
|
||||
StatusImported TaskStatus = "imported"
|
||||
StatusAtomizing TaskStatus = "atomizing"
|
||||
StatusAtomized TaskStatus = "atomized"
|
||||
StatusPublished TaskStatus = "published"
|
||||
StatusClaimRequested TaskStatus = "claim_requested"
|
||||
StatusAssigned TaskStatus = "assigned"
|
||||
StatusInProgress TaskStatus = "in_progress"
|
||||
StatusInReview TaskStatus = "in_review"
|
||||
StatusChangesRequested TaskStatus = "changes_requested"
|
||||
StatusApproved TaskStatus = "approved"
|
||||
StatusArchived TaskStatus = "archived"
|
||||
)
|
||||
|
||||
// AllStatuses lists every valid status (used by tests and validation).
|
||||
var AllStatuses = []TaskStatus{
|
||||
StatusImported, StatusAtomizing, StatusAtomized, StatusPublished,
|
||||
StatusClaimRequested, StatusAssigned, StatusInProgress, StatusInReview,
|
||||
StatusChangesRequested, StatusApproved, StatusArchived,
|
||||
}
|
||||
|
||||
func (s TaskStatus) Valid() bool {
|
||||
for _, v := range AllStatuses {
|
||||
if s == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// transitions encodes §4.4 exactly:
|
||||
//
|
||||
// imported ──subdivide──► atomizing ──service ok──► atomized (children created atomized)
|
||||
// atomizing ──service failed──► imported (recovery; archived also allowed from any)
|
||||
// atomized ──publish──► published
|
||||
// published ──dev requests──► claim_requested ──approve──► assigned
|
||||
// published ──assign AI──► assigned
|
||||
// assigned ──dev starts──► in_progress ──dev submits──► in_review
|
||||
// assigned ──AI callback succeeded──► in_review
|
||||
// in_review ──► approved | changes_requested ──► in_progress
|
||||
// claim_requested ──withdraw/decline──► published
|
||||
// assigned|in_progress ──unassign/abandon──► published
|
||||
// any ──► archived (terminal)
|
||||
var transitions = map[TaskStatus]map[TaskStatus]bool{
|
||||
StatusImported: {StatusAtomizing: true},
|
||||
StatusAtomizing: {StatusAtomized: true, StatusImported: true},
|
||||
StatusAtomized: {StatusPublished: true, StatusAtomizing: true}, // re-run subdivision
|
||||
StatusPublished: {StatusClaimRequested: true, StatusAssigned: true},
|
||||
StatusClaimRequested: {
|
||||
StatusAssigned: true,
|
||||
StatusPublished: true,
|
||||
},
|
||||
StatusAssigned: {
|
||||
StatusInProgress: true,
|
||||
StatusInReview: true, // AI work-performer callback path
|
||||
StatusPublished: true,
|
||||
},
|
||||
StatusInProgress: {
|
||||
StatusInReview: true,
|
||||
StatusPublished: true,
|
||||
},
|
||||
StatusInReview: {
|
||||
StatusApproved: true,
|
||||
StatusChangesRequested: true,
|
||||
StatusAssigned: true, // AI job failed → back to assigned (§5.2)
|
||||
},
|
||||
StatusChangesRequested: {StatusInProgress: true},
|
||||
StatusApproved: {},
|
||||
StatusArchived: {},
|
||||
}
|
||||
|
||||
// CanTransition reports whether from → to is a legal §4.4 move. Archiving is
|
||||
// allowed from any state except archived itself.
|
||||
func CanTransition(from, to TaskStatus) bool {
|
||||
if !from.Valid() || !to.Valid() {
|
||||
return false
|
||||
}
|
||||
if to == StatusArchived {
|
||||
return from != StatusArchived
|
||||
}
|
||||
return transitions[from][to]
|
||||
}
|
||||
|
||||
// ErrBadTransition describes a rejected move for error envelopes.
|
||||
type ErrBadTransition struct {
|
||||
From, To TaskStatus
|
||||
}
|
||||
|
||||
func (e *ErrBadTransition) Error() string {
|
||||
return fmt.Sprintf("illegal status transition %s → %s", e.From, e.To)
|
||||
}
|
||||
|
||||
// Transition validates and returns the error form handlers translate to 409.
|
||||
func Transition(from, to TaskStatus) error {
|
||||
if !CanTransition(from, to) {
|
||||
return &ErrBadTransition{From: from, To: to}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestTransitionMatrixExhaustive enumerates every (from, to) pair and pins
|
||||
// the §4.4 machine exactly: the allowed set below is the complete truth.
|
||||
func TestTransitionMatrixExhaustive(t *testing.T) {
|
||||
allowed := map[TaskStatus]map[TaskStatus]bool{
|
||||
StatusImported: {StatusAtomizing: true, StatusArchived: true},
|
||||
StatusAtomizing: {StatusAtomized: true, StatusImported: true, StatusArchived: true},
|
||||
StatusAtomized: {StatusPublished: true, StatusAtomizing: true, StatusArchived: true},
|
||||
StatusPublished: {StatusClaimRequested: true, StatusAssigned: true, StatusArchived: true},
|
||||
StatusClaimRequested: {StatusAssigned: true, StatusPublished: true, StatusArchived: true},
|
||||
StatusAssigned: {StatusInProgress: true, StatusInReview: true, StatusPublished: true, StatusArchived: true},
|
||||
StatusInProgress: {StatusInReview: true, StatusPublished: true, StatusArchived: true},
|
||||
StatusInReview: {StatusApproved: true, StatusChangesRequested: true, StatusAssigned: true, StatusArchived: true},
|
||||
StatusChangesRequested: {StatusInProgress: true, StatusArchived: true},
|
||||
StatusApproved: {StatusArchived: true},
|
||||
StatusArchived: {},
|
||||
}
|
||||
|
||||
for _, from := range AllStatuses {
|
||||
for _, to := range AllStatuses {
|
||||
want := allowed[from][to]
|
||||
if got := CanTransition(from, to); got != want {
|
||||
t.Errorf("CanTransition(%s, %s) = %v, want %v", from, to, got, want)
|
||||
}
|
||||
err := Transition(from, to)
|
||||
if want && err != nil {
|
||||
t.Errorf("Transition(%s, %s) unexpected error %v", from, to, err)
|
||||
}
|
||||
if !want && err == nil {
|
||||
t.Errorf("Transition(%s, %s) should error", from, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransitionRejectsUnknownStatuses(t *testing.T) {
|
||||
if CanTransition("bogus", StatusArchived) {
|
||||
t.Error("unknown from-status must be rejected")
|
||||
}
|
||||
if CanTransition(StatusImported, "bogus") {
|
||||
t.Error("unknown to-status must be rejected")
|
||||
}
|
||||
if CanTransition(StatusImported, StatusImported) {
|
||||
t.Error("self-transition must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusValid(t *testing.T) {
|
||||
for _, s := range AllStatuses {
|
||||
if !s.Valid() {
|
||||
t.Errorf("%s should be valid", s)
|
||||
}
|
||||
}
|
||||
if TaskStatus("nope").Valid() {
|
||||
t.Error("nope should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeBounty(t *testing.T) {
|
||||
tests := []struct {
|
||||
coeff, budget, want float64
|
||||
}{
|
||||
{0.25, 1000, 250},
|
||||
{0.2, 1000, 200},
|
||||
{1.0 / 3.0, 1000, 333.33},
|
||||
{0.333, 100, 33.3},
|
||||
{0, 1000, 0},
|
||||
{1, 0, 0},
|
||||
{0.01, 99.99, 1.0}, // 0.9999 rounds to 1.00
|
||||
{0.005, 100, 0.5},
|
||||
{2.0, 500, 1000}, // extension coefficients may exceed 1 (§5.1)
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := ComputeBounty(tt.coeff, tt.budget); got != tt.want {
|
||||
t.Errorf("ComputeBounty(%v, %v) = %v, want %v", tt.coeff, tt.budget, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskHelpers(t *testing.T) {
|
||||
task := &Task{
|
||||
Assignee: &Assignee{Kind: "human", UserID: "dev1"},
|
||||
ClaimRequests: []ClaimRequest{{DeveloperID: "dev2"}},
|
||||
}
|
||||
if !task.IsAssignedTo("dev1") || task.IsAssignedTo("dev2") {
|
||||
t.Error("IsAssignedTo wrong")
|
||||
}
|
||||
if !task.HasClaimFrom("dev2") || task.HasClaimFrom("dev1") {
|
||||
t.Error("HasClaimFrom wrong")
|
||||
}
|
||||
aiTask := &Task{Assignee: &Assignee{Kind: "ai", JobID: "wp_1"}}
|
||||
if aiTask.IsAssignedTo("dev1") {
|
||||
t.Error("ai assignee must not match human checks")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TaskOrigin string
|
||||
|
||||
const (
|
||||
OriginImported TaskOrigin = "imported"
|
||||
OriginSubdivided TaskOrigin = "subdivided"
|
||||
OriginExtended TaskOrigin = "extended"
|
||||
)
|
||||
|
||||
type External struct {
|
||||
System string `bson:"system" json:"system"`
|
||||
Key string `bson:"key" json:"key"`
|
||||
URL string `bson:"url" json:"url"`
|
||||
Type string `bson:"type" json:"type"` // epic | story | task (icon only)
|
||||
Raw map[string]any `bson:"raw" json:"-"`
|
||||
Orphaned bool `bson:"orphaned" json:"orphaned"`
|
||||
ContentHash string `bson:"contentHash" json:"-"` // change detection for upstream edits
|
||||
}
|
||||
|
||||
type TaskAttachment struct {
|
||||
Name string `bson:"name" json:"name"`
|
||||
URL string `bson:"url" json:"url"`
|
||||
MimeType string `bson:"mimeType" json:"mimeType"`
|
||||
FileID string `bson:"fileId,omitempty" json:"fileId,omitempty"`
|
||||
}
|
||||
|
||||
type Assignee struct {
|
||||
Kind string `bson:"kind" json:"kind"` // human | ai
|
||||
UserID string `bson:"userId,omitempty" json:"userId,omitempty"`
|
||||
JobID string `bson:"jobId,omitempty" json:"jobId,omitempty"`
|
||||
}
|
||||
|
||||
type ClaimRequest struct {
|
||||
DeveloperID string `bson:"developerId" json:"developerId"`
|
||||
Note string `bson:"note" json:"note"`
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
}
|
||||
|
||||
type TimelineEntry struct {
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
ActorID string `bson:"actorId" json:"actorId"` // user id or "system"
|
||||
Event string `bson:"event" json:"event"`
|
||||
Data map[string]any `bson:"data,omitempty" json:"data,omitempty"`
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
AuthorID string `bson:"authorId" json:"authorId"`
|
||||
Body string `bson:"body" json:"body"` // sanitized HTML
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
}
|
||||
|
||||
type TimeLogEntry struct {
|
||||
DeveloperID string `bson:"developerId" json:"developerId"`
|
||||
Minutes int `bson:"minutes" json:"minutes"`
|
||||
Note string `bson:"note" json:"note"`
|
||||
At time.Time `bson:"at" json:"at"`
|
||||
}
|
||||
|
||||
// Task is the central work item (§4.4).
|
||||
type Task struct {
|
||||
ID string `bson:"_id" json:"id"`
|
||||
CustomerID string `bson:"customerId" json:"customerId"`
|
||||
ConsultantID string `bson:"consultantId" json:"consultantId"`
|
||||
Origin TaskOrigin `bson:"origin" json:"origin"`
|
||||
ParentID string `bson:"parentId,omitempty" json:"parentId,omitempty"`
|
||||
RootID string `bson:"rootId" json:"rootId"`
|
||||
External *External `bson:"external,omitempty" json:"external,omitempty"`
|
||||
Title string `bson:"title" json:"title"`
|
||||
Description string `bson:"description" json:"description"`
|
||||
AcceptanceCriteria []string `bson:"acceptanceCriteria" json:"acceptanceCriteria"`
|
||||
Attachments []TaskAttachment `bson:"attachments" json:"attachments"`
|
||||
Links []string `bson:"links" json:"links"`
|
||||
EffortCoefficient float64 `bson:"effortCoefficient" json:"effortCoefficient"`
|
||||
Budget float64 `bson:"budget" json:"budget"`
|
||||
Bounty float64 `bson:"bounty" json:"bounty"`
|
||||
Status TaskStatus `bson:"status" json:"status"`
|
||||
Assignee *Assignee `bson:"assignee,omitempty" json:"assignee,omitempty"`
|
||||
ClaimRequests []ClaimRequest `bson:"claimRequests" json:"claimRequests"`
|
||||
AtomizationNote string `bson:"atomizationNote,omitempty" json:"atomizationNote,omitempty"`
|
||||
Timeline []TimelineEntry `bson:"timeline" json:"timeline"`
|
||||
Comments []Comment `bson:"comments" json:"comments"`
|
||||
TimeLog []TimeLogEntry `bson:"timeLog" json:"timeLog"`
|
||||
PublishedAt time.Time `bson:"publishedAt,omitempty" json:"publishedAt,omitempty"` // stale-task aging (§11.19)
|
||||
CreatedAt time.Time `bson:"createdAt" json:"createdAt"`
|
||||
UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"`
|
||||
Version int64 `bson:"version" json:"version"`
|
||||
}
|
||||
|
||||
// ComputeBounty implements §6.1: round(coefficient × budget, 2).
|
||||
func ComputeBounty(coefficient, budget float64) float64 {
|
||||
return math.Round(coefficient*budget*100) / 100
|
||||
}
|
||||
|
||||
// IsAssignedTo reports whether the human developer holds the task.
|
||||
func (t *Task) IsAssignedTo(userID string) bool {
|
||||
return t.Assignee != nil && t.Assignee.Kind == "human" && t.Assignee.UserID == userID
|
||||
}
|
||||
|
||||
// HasClaimFrom reports an open claim request by the developer.
|
||||
func (t *Task) HasClaimFrom(developerID string) bool {
|
||||
for _, cr := range t.ClaimRequests {
|
||||
if cr.DeveloperID == developerID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user