Documentation
¶
Overview ¶
Package contracts is everything another module, an app or a test may know about tasks: the entity, the events, the permissions and the Service interface. The implementation is in ../internal, which nothing outside this module can import, so a consumer that compiles took a capability rather than an implementation.
A task is an assignable, SLA-tracked unit of work. Its point is the SLA: the entity exists to make "resolve within N hours or escalate" an auditable fact rather than a report somebody runs.
Index ¶
Constants ¶
const ( EventCreated = "task.task.created" EventUpdated = "task.task.updated" EventDeleted = "task.task.deleted" EventAssigned = "task.assigned" EventResolved = "task.resolved" EventSLABreached = "task.sla_breached" )
The six events this module emits. The first three are kit/rest's, published by the Spec module.go mounts; the last three are the lifecycle's, published by the commands. Both sets are listed in the manifest, and kit/app refuses to start if a route would publish one that is not.
A subscriber names one of these constants rather than a string, so renaming an event is a compile error in every module that listens for it.
const ( PermissionTaskRead = "task:read" PermissionTaskUpdate = "task:update" )
The two permissions a task has. They are named after the resource and not after the module, because a permission outlives the module that first defined it, and there are two of them rather than five because reading and changing are the only distinction anybody has yet wanted to grant separately.
The list the manifest declares is in ../module.go, with the manifest. Two strings are what a consumer needs to guard a route of its own; the module.Permission values around them are the kernel's shape, and keeping them out of here keeps kit/module out of this package's build graph — a contracts package is what a consumer compiles against, so everything it imports is something every consumer imports.
const ( StatusOpen = "open" StatusAcknowledged = "acknowledged" StatusInProgress = "in_progress" StatusResolved = "resolved" StatusClosed = "closed" )
The lifecycle: a task opens, is acknowledged when somebody takes it, is resolved when the loop closes, and is closed when nothing more will happen. Nothing may be assigned once it is resolved or closed.
const ( PriorityLow = "low" PriorityNormal = "normal" PriorityHigh = "high" PriorityCritical = "critical" )
The priorities, which order a queue and drive escalation.
Variables ¶
var Events = []string{EventCreated, EventUpdated, EventDeleted, EventAssigned, EventResolved, EventSLABreached}
Events is every event this module emits, for the manifest.
Functions ¶
This section is empty.
Types ¶
type Assigned ¶
type Assigned struct {
TaskID uuid.UUID `json:"taskId"`
Assignee uuid.UUID `json:"assigneeId"`
// Status is what the assignment moved the task to, so a subscriber that
// only wants acknowledgements does not have to read the task back.
Status string `json:"status"`
At time.Time `json:"at"`
}
Assigned is the payload of EventAssigned: somebody is now responsible.
type Resolved ¶
type Resolved struct {
TaskID uuid.UUID `json:"taskId"`
Resolution string `json:"resolution,omitempty"`
At time.Time `json:"at"`
}
Resolved is the payload of EventResolved: the loop is closed.
type SLABreached ¶
type SLABreached struct {
TaskID uuid.UUID `json:"taskId"`
Priority string `json:"priority"`
Deadline time.Time `json:"deadline"`
At time.Time `json:"at"`
}
SLABreached is the payload of EventSLABreached: the promise was broken. It carries the priority and the deadline because escalation is decided from those two and a subscriber should not need a second query to escalate.
type Service ¶
type Service interface {
// Assign makes assignee responsible and acknowledges an open task. The
// same person again changes nothing and publishes nothing.
Assign(ctx context.Context, tx db.Tx[db.Tenant], id, assignee uuid.UUID) (*Task, error)
// Resolve closes the loop. The same resolution again, or none, changes
// nothing; a different one is a conflict, because a resolved task's account
// of itself is not something a retry may quietly rewrite.
Resolve(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID, resolution string) (*Task, error)
// CheckSLA records a breach if the deadline has passed and the task is
// neither resolved nor already breached. The sweep calls it once a minute
// for every overdue task, so it publishes at most once per task.
CheckSLA(ctx context.Context, tx db.Tx[db.Tenant], id uuid.UUID) (*Task, error)
}
Service is the task lifecycle: the three transitions generic CRUD cannot safely infer, because each is a rule about the state it came from and each publishes an event. Everything else about a task is the five routes kit/rest mounts.
Every command takes the caller's transaction rather than opening one. An HTTP handler, a periodic job and another module's event handler each already hold one, and the state change and its event belong in that one — which is also why the implementation needs no dependencies at all.
The three errors a caller can act on are kit/crud's, so one mapping answers for the lifecycle routes and the CRUD routes alike:
crud.ErrNotFound no such task in this tenant crud.ErrConflict the task is in a state that refuses this command crud.ErrInvalid the argument is not one the command can use
Each command is idempotent when repeated with the same argument: the callers that retry — a browser, a redelivered event, the next sweep — must not each produce an event.
type Task ¶
type Task struct {
crud.Base
// Title is the one-line summary. It is what a list screen shows.
Title string `` /* 179-byte string literal not displayed */
// Description is the optional long form.
Description string `json:"description,omitempty" gorm:"type:text" ui:"widget:textarea;hide:list" doc:"Detailed description of the task"`
// Status and Priority are closed sets; the enum tag is what a form renders
// as a select and what Validate refuses a value outside.
//
// Both carry required:"false", and so does SLABreached below. huma reads a
// struct field with no omitempty as a required request property, so without
// it a create would have to send the three fields the server sets itself —
// which is the same reasoning as crud.Base's, one level out.
Status string `` /* 187-byte string literal not displayed */
Priority string `` /* 170-byte string literal not displayed */
// Source and SourceRef link a task back to whatever raised it — a sensor, a
// form — without a foreign key into another module's table. That is what
// "cross-module dependencies are Go interfaces" costs at the database, and
// it is cheaper than the alternative.
Source string `json:"source,omitempty" gorm:"type:varchar(40)" ui:"hide:list" doc:"Origin of the task" example:"sensor"`
SourceRef string `` /* 131-byte string literal not displayed */
// AssigneeID is who is responsible, nil while nobody is. Service.Assign
// sets it, not a PATCH: assignment moves the status and publishes.
AssigneeID *uuid.UUID `json:"assigneeId,omitempty" gorm:"type:uuid" ui:"widget:entity-picker" doc:"User responsible for the task" format:"uuid"`
// DueAt is the soft target and SLADeadline the contractual one: two fields,
// because "due soon" and "the promise is broken" are different things to
// show, and only the second is what SLABreached is measured against.
DueAt *time.Time `json:"dueAt,omitempty" gorm:"type:timestamptz" ui:"widget:datetime" doc:"Soft target completion time"`
SLADeadline *time.Time `` /* 132-byte string literal not displayed */
// SLABreached is stored rather than derived, so that a breach stays a fact
// after the task is resolved and the deadline stops being in the future.
SLABreached bool `` /* 164-byte string literal not displayed */
// ResolvedAt and Resolution close the loop. Both are set by Service.Resolve.
ResolvedAt *time.Time `` /* 131-byte string literal not displayed */
Resolution string `json:"resolution,omitempty" gorm:"type:text" ui:"widget:textarea;hide:list" doc:"How the task was resolved"`
}
Task is an assignable work item with a priority, a soft due date and a hard SLA deadline.
The struct is the whole surface: the json tags are the API, the gorm tags are the table, the enum and validate tags are the schema a generated screen reads (kit/crud.Schema), and crud.Base contributes the id, the timestamps, the soft delete and the tenant column that row-level security matches on.
func (*Task) IsOverdue ¶
IsOverdue reports whether the deadline has passed with the task unresolved. It is the one definition of a breach: the sweep in internal/sla.go and Service.CheckSLA both ask it rather than comparing timestamps themselves.