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
|
||||
}
|
||||
Reference in New Issue
Block a user