agent build
This commit is contained in:
595
go-api/internal/tools/assignments.go
Normal file
595
go-api/internal/tools/assignments.go
Normal file
@@ -0,0 +1,595 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
)
|
||||
|
||||
// Assignments: the first tools that change something, and the two lookups that
|
||||
// make changing something possible.
|
||||
//
|
||||
// §4 has a line that dictates the shape of this file: "a tool description that
|
||||
// requires the model to guess an ID it has not been given is a design bug. Add
|
||||
// a lookup tool instead." Every read tool built so far returns aggregates —
|
||||
// counts, averages, the weakest five — and none of them return a row id,
|
||||
// deliberately: an id in an analytics answer is noise. But an assignment names
|
||||
// a posting and a person, so the write is unusable until the model has a
|
||||
// legitimate way to learn those two things.
|
||||
//
|
||||
// Hence three tools, in the order an agent actually uses them:
|
||||
//
|
||||
// open_positions → which roles need people, with their ids
|
||||
// available_workers → who is free in that window, with their emails
|
||||
// assign_worker → put one on the other, once a person has said yes
|
||||
//
|
||||
// The alternative — a single write that accepts a worker's name and resolves it
|
||||
// itself — reads as friendlier and is considerably worse. Two people called
|
||||
// Chen makes it ambiguous, and the disambiguation would happen inside a write,
|
||||
// after approval, with no one watching.
|
||||
|
||||
/* ── Lookup: open positions ─────────────────────────────────────────────── */
|
||||
|
||||
type openPositionsInput struct {
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// OpenPositions lists roles that still need people, with the ids to fill them.
|
||||
func OpenPositions(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "open_positions",
|
||||
Description: "List the roles that are currently open, with how many people each " +
|
||||
"needs, how many are already assigned, and how many are still to fill. " +
|
||||
"Returns an id for each role. Use this before assigning anyone: it is the " +
|
||||
"only way to learn a role's id, and an id must never be guessed.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"limit": map[string]any{
|
||||
"type": "integer", "minimum": 1, "maximum": 100,
|
||||
"description": "How many roles to list, most urgent first. Defaults to 20.",
|
||||
},
|
||||
},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorizeAs(tc, "job-postings", "p")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
var in openPositionsInput
|
||||
if len(inputs) > 0 {
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
}
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// Only roles that can actually be staffed. A draft has not been
|
||||
// agreed, a paused role has been stopped on purpose, and a closed
|
||||
// one is history — offering any of them as assignable would invite
|
||||
// the agent to staff a role nobody is hiring for.
|
||||
q.raw("p.status = 'active'")
|
||||
|
||||
args := append(append([]any{}, q.args...), limit)
|
||||
rows, err := db.Query(ctx, `
|
||||
SELECT p.id::text, p.title, p.role_category, p.location,
|
||||
p.headcount, p.priority::text, p.start_date,
|
||||
(SELECT count(*) FROM assignments a
|
||||
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id
|
||||
AND a.status = 'active')
|
||||
FROM job_postings p
|
||||
WHERE `+q.clause()+`
|
||||
ORDER BY p.priority DESC, p.created_date ASC
|
||||
LIMIT $`+fmt.Sprint(len(args)), args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the open roles could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
positions := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var (
|
||||
id, title, category, location, priority string
|
||||
headcount int
|
||||
startDate *time.Time
|
||||
assigned int
|
||||
)
|
||||
if err := rows.Scan(&id, &title, &category, &location,
|
||||
&headcount, &priority, &startDate, &assigned); err != nil {
|
||||
return Failf(CodeFailed, "the open roles could not be read")
|
||||
}
|
||||
stillToFill := headcount - assigned
|
||||
if stillToFill < 0 {
|
||||
stillToFill = 0
|
||||
}
|
||||
p := map[string]any{
|
||||
"id": id, "title": title, "priority": priority,
|
||||
"headcount": headcount, "assigned": assigned, "stillToFill": stillToFill,
|
||||
}
|
||||
if category != "" {
|
||||
p["roleCategory"] = category
|
||||
}
|
||||
if location != "" {
|
||||
p["location"] = location
|
||||
}
|
||||
if startDate != nil {
|
||||
p["startDate"] = startDate.Format("2006-01-02")
|
||||
}
|
||||
positions = append(positions, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the open roles could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{"positions": positions, "count": len(positions)}
|
||||
if len(positions) == 0 {
|
||||
data["note"] = "No roles are open. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Lookup: available workers ──────────────────────────────────────────── */
|
||||
|
||||
type availableWorkersInput struct {
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
Limit int `json:"limit"`
|
||||
}
|
||||
|
||||
// AvailableWorkers lists workers with no clashing assignment in a window.
|
||||
//
|
||||
// "Available" here means one specific, checkable thing: no active assignment
|
||||
// overlapping the window. It does not mean willing, qualified, or within their
|
||||
// contracted hours. The description says so, because a model given a tool
|
||||
// called `available_workers` will otherwise report its output as availability
|
||||
// in the ordinary sense of the word, and a manager will read it that way.
|
||||
func AvailableWorkers(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "available_workers",
|
||||
Description: "List workers who have no clashing assignment in a given window, " +
|
||||
"best-scoring first, with the email needed to assign them. Availability here " +
|
||||
"means only that nothing else is booked over that window — it does not mean " +
|
||||
"the person has agreed, is qualified for the role, or is within their hours. " +
|
||||
"Say so when reporting it.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"starts_at": map[string]any{
|
||||
"type": "string",
|
||||
"description": "When the work starts, as an RFC 3339 timestamp " +
|
||||
"(for example 2026-09-12T18:00:00Z). Required.",
|
||||
},
|
||||
"ends_at": map[string]any{
|
||||
"type": "string",
|
||||
"description": "When the work ends, as an RFC 3339 timestamp. " +
|
||||
"Omit for open-ended work.",
|
||||
},
|
||||
"limit": map[string]any{
|
||||
"type": "integer", "minimum": 1, "maximum": 100,
|
||||
"description": "How many workers to list. Defaults to 10.",
|
||||
},
|
||||
},
|
||||
"required": []string{"starts_at"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectRead,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
q, denied := authorizeAs(tc, "worker-profiles", "w")
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
var in availableWorkersInput
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
return Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
}
|
||||
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
|
||||
if bad != nil {
|
||||
return *bad
|
||||
}
|
||||
limit := in.Limit
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
if limit > 100 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
// The clash test is a NOT EXISTS against the same tenant, so it is
|
||||
// a pre-filter like every other predicate here rather than a list
|
||||
// fetched and then thinned in Go.
|
||||
args := append(append([]any{}, q.args...), starts, ends, limit)
|
||||
startIdx, endIdx, limIdx := len(args)-2, len(args)-1, len(args)
|
||||
rows, err := db.Query(ctx, fmt.Sprintf(`
|
||||
SELECT w.full_name, w.email::text, nullif(w.krow_score, 0),
|
||||
nullif(w.reliability_score, 0), nullif(w.experience_years, 0),
|
||||
w.current_position
|
||||
FROM worker_profiles w
|
||||
WHERE %s
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM assignments a
|
||||
WHERE a.org_id = w.org_id
|
||||
AND a.worker_email = w.email
|
||||
AND a.status = 'active'
|
||||
AND tstzrange(a.starts_at, coalesce(a.ends_at, 'infinity'::timestamptz))
|
||||
&& tstzrange($%d, coalesce($%d::timestamptz, 'infinity'::timestamptz)))
|
||||
ORDER BY w.krow_score DESC NULLS LAST, w.reliability_score DESC NULLS LAST
|
||||
LIMIT $%d`, q.clause(), startIdx, endIdx, limIdx), args...)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
workers := []map[string]any{}
|
||||
for rows.Next() {
|
||||
// Pointers, so an unrated worker carries no rating rather than a
|
||||
// 0 the model would read as the worst possible score. This list
|
||||
// feeds assign_worker, so the distinction picks who gets offered.
|
||||
var (
|
||||
name, email, position string
|
||||
krow, reliability, experience *int
|
||||
)
|
||||
if err := rows.Scan(&name, &email, &krow, &reliability, &experience, &position); err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
w := map[string]any{"name": name, "email": email}
|
||||
if krow != nil {
|
||||
w["krowScore"] = *krow
|
||||
}
|
||||
if reliability != nil {
|
||||
w["reliabilityScore"] = *reliability
|
||||
}
|
||||
if experience != nil {
|
||||
w["experienceYears"] = *experience
|
||||
}
|
||||
if position != "" {
|
||||
w["currentPosition"] = position
|
||||
}
|
||||
workers = append(workers, w)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return Failf(CodeFailed, "the worker profiles could not be read")
|
||||
}
|
||||
|
||||
data := map[string]any{
|
||||
"window": windowLabel(starts, ends),
|
||||
"workers": workers,
|
||||
"count": len(workers),
|
||||
"meaning": "No clashing assignment in this window. Not a statement that " +
|
||||
"they have agreed or are qualified.",
|
||||
}
|
||||
if len(workers) == 0 {
|
||||
data["note"] = "Nobody is free in that window. This is a real answer, not a failure to look."
|
||||
}
|
||||
return OK(data)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Write: assign a worker ─────────────────────────────────────────────── */
|
||||
|
||||
type assignWorkerInput struct {
|
||||
JobPostingID string `json:"job_posting_id"`
|
||||
WorkerEmail string `json:"worker_email"`
|
||||
StartsAt string `json:"starts_at"`
|
||||
EndsAt string `json:"ends_at"`
|
||||
}
|
||||
|
||||
// AssignWorker puts a worker on a role. The first tool in this service that
|
||||
// changes anything.
|
||||
//
|
||||
// The pattern every future write should copy is the split between Confirm and
|
||||
// Handler, and specifically what is duplicated across them. Both authorize.
|
||||
// Both resolve the posting and the worker. Both check for a clash. That looks
|
||||
// like repetition and is not: the renderer runs to describe, and the handler
|
||||
// runs after a person has read that description and agreed — with an unbounded
|
||||
// gap in between, during which somebody else may have taken the same shift.
|
||||
//
|
||||
// So the renderer's clash check produces a WARNING, and the handler's produces
|
||||
// a REFUSAL. A description is about the moment it was written; a write is about
|
||||
// the moment it happens.
|
||||
func AssignWorker(db repo.Querier) Tool {
|
||||
return Tool{
|
||||
Name: "assign_worker",
|
||||
Description: "Assign a worker to an open role for a given period. This creates a " +
|
||||
"real assignment: the person is scheduled to work. Requires a role id from " +
|
||||
"open_positions and a worker email from available_workers — never invent " +
|
||||
"either. A person must approve before this takes effect.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"job_posting_id": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The role's id, exactly as returned by open_positions.",
|
||||
},
|
||||
"worker_email": map[string]any{
|
||||
"type": "string",
|
||||
"description": "The worker's email, exactly as returned by available_workers.",
|
||||
},
|
||||
"starts_at": map[string]any{
|
||||
"type": "string",
|
||||
"description": "When the work starts, as an RFC 3339 timestamp. Required.",
|
||||
},
|
||||
"ends_at": map[string]any{
|
||||
"type": "string",
|
||||
"description": "When the work ends, as an RFC 3339 timestamp. Omit for open-ended.",
|
||||
},
|
||||
},
|
||||
"required": []string{"job_posting_id", "worker_email", "starts_at"},
|
||||
"additionalProperties": false,
|
||||
},
|
||||
Effect: EffectWrite,
|
||||
MaxResultBytes: DefaultMaxResultBytes,
|
||||
|
||||
Confirm: func(ctx context.Context, tc Context, inputs json.RawMessage) (*Confirmation, *Result) {
|
||||
plan, denied := planAssignment(ctx, db, tc, inputs)
|
||||
if denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
|
||||
details := []Detail{
|
||||
{Label: "Worker", Value: plan.workerName},
|
||||
{Label: "Role", Value: plan.postingTitle},
|
||||
{Label: "Period", Value: windowLabel(plan.starts, plan.ends)},
|
||||
}
|
||||
if plan.location != "" {
|
||||
details = append(details, Detail{Label: "Location", Value: plan.location})
|
||||
}
|
||||
details = append(details, Detail{
|
||||
Label: "Role filled",
|
||||
Value: fmt.Sprintf("%d of %d, this would make %d",
|
||||
plan.assigned, plan.headcount, plan.assigned+1),
|
||||
})
|
||||
|
||||
var warnings []string
|
||||
if plan.clashes > 0 {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"%s already has %s over this period. Assigning them will double-book.",
|
||||
plan.workerName, plural(plan.clashes, "assignment", "assignments")))
|
||||
}
|
||||
if plan.assigned >= plan.headcount {
|
||||
warnings = append(warnings, fmt.Sprintf(
|
||||
"%s already has all %s it asked for. This would go over headcount.",
|
||||
plan.postingTitle, plural(plan.headcount, "person", "people")))
|
||||
}
|
||||
if plan.starts.Before(time.Now()) {
|
||||
warnings = append(warnings, "This period starts in the past.")
|
||||
}
|
||||
|
||||
return &Confirmation{
|
||||
Title: fmt.Sprintf("Assign %s to %s", plan.workerName, plan.postingTitle),
|
||||
Summary: fmt.Sprintf(
|
||||
"%s will be scheduled to work %s as %s. They will appear on the roster "+
|
||||
"for this role and count towards its headcount.",
|
||||
plan.workerName, windowLabel(plan.starts, plan.ends), plan.postingTitle),
|
||||
Details: details,
|
||||
Warnings: warnings,
|
||||
}, nil
|
||||
},
|
||||
|
||||
Handler: func(ctx context.Context, tc Context, inputs json.RawMessage) Result {
|
||||
plan, denied := planAssignment(ctx, db, tc, inputs)
|
||||
if denied != nil {
|
||||
return *denied
|
||||
}
|
||||
|
||||
// Re-checked here, not merely described above. The approval was
|
||||
// given against a picture of the world that is now some minutes
|
||||
// old, and a double-booking created between the asking and the
|
||||
// answering is one nobody agreed to.
|
||||
if plan.clashes > 0 {
|
||||
return Failf(CodeFailed,
|
||||
"%s was booked over this period since this was approved; nothing was assigned",
|
||||
plan.workerName)
|
||||
}
|
||||
|
||||
var id string
|
||||
err := db.QueryRow(ctx, `
|
||||
INSERT INTO assignments
|
||||
(org_id, job_posting_id, worker_profile_id, worker_email,
|
||||
worker_name, starts_at, ends_at, status, source, match_score)
|
||||
VALUES ($1::uuid, $2::uuid, $3, $4, $5, $6, $7, 'active', 'agent', $8)
|
||||
RETURNING id::text`,
|
||||
tc.OrgID(), plan.postingID, plan.workerProfileID, plan.workerEmail,
|
||||
plan.workerName, plan.starts, nullableTime(plan.ends), plan.matchScore,
|
||||
).Scan(&id)
|
||||
if err != nil {
|
||||
return Failf(CodeFailed, "the assignment could not be created")
|
||||
}
|
||||
|
||||
return OK(map[string]any{
|
||||
"assignmentId": id,
|
||||
"worker": plan.workerName,
|
||||
"role": plan.postingTitle,
|
||||
"period": windowLabel(plan.starts, plan.ends),
|
||||
"status": "active",
|
||||
"confirmed": true,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// assignmentPlan is everything both Confirm and Handler need, resolved once.
|
||||
type assignmentPlan struct {
|
||||
postingID string
|
||||
postingTitle string
|
||||
location string
|
||||
headcount int
|
||||
assigned int
|
||||
workerProfileID *string
|
||||
workerEmail string
|
||||
workerName string
|
||||
matchScore *int
|
||||
starts time.Time
|
||||
ends *time.Time
|
||||
clashes int
|
||||
}
|
||||
|
||||
// planAssignment authorizes, validates and resolves an assign_worker call.
|
||||
//
|
||||
// Shared by the renderer and the handler so that the thing described and the
|
||||
// thing done are resolved by the same code. Two separate resolutions would
|
||||
// drift, and the drift would land exactly where nobody looks: between what a
|
||||
// person approved and what then happened.
|
||||
//
|
||||
// Every refusal is the same opaque Denied(). A posting id that belongs to
|
||||
// another tenant, one that does not exist, and one this caller may not see are
|
||||
// all indistinguishable — otherwise assign_worker becomes a way to ask whether
|
||||
// a given uuid is real.
|
||||
func planAssignment(ctx context.Context, db repo.Querier, tc Context, inputs json.RawMessage) (*assignmentPlan, *Result) {
|
||||
// Create, not List. `assignments` lists to everyone and creates for
|
||||
// operators only, so a talent caller is refused here even though they may
|
||||
// read their own roster perfectly well.
|
||||
if _, denied := authorizeOp(tc, "assignments", domain.OpCreate, ""); denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
|
||||
var in assignWorkerInput
|
||||
if err := json.Unmarshal(inputs, &in); err != nil {
|
||||
bad := Failf(CodeInvalidInput, "the arguments were not valid JSON")
|
||||
return nil, &bad
|
||||
}
|
||||
if strings.TrimSpace(in.JobPostingID) == "" || strings.TrimSpace(in.WorkerEmail) == "" {
|
||||
bad := Failf(CodeInvalidInput, "a role id and a worker email are both required")
|
||||
return nil, &bad
|
||||
}
|
||||
starts, ends, bad := decodeWindow(in.StartsAt, in.EndsAt)
|
||||
if bad != nil {
|
||||
return nil, bad
|
||||
}
|
||||
|
||||
plan := &assignmentPlan{starts: starts, ends: ends}
|
||||
|
||||
// The posting, behind the caller's own read predicate. Referencing a row
|
||||
// the caller could not have read would confirm it exists.
|
||||
pq, denied := authorizeAs(tc, "job-postings", "p")
|
||||
if denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
pq.eq("id::text", strings.TrimSpace(in.JobPostingID))
|
||||
pq.raw("p.status = 'active'")
|
||||
err := db.QueryRow(ctx, `
|
||||
SELECT p.id::text, p.title, p.location, p.headcount,
|
||||
(SELECT count(*) FROM assignments a
|
||||
WHERE a.job_posting_id = p.id AND a.org_id = p.org_id AND a.status = 'active')
|
||||
FROM job_postings p
|
||||
WHERE `+pq.clause(), pq.args...,
|
||||
).Scan(&plan.postingID, &plan.postingTitle, &plan.location, &plan.headcount, &plan.assigned)
|
||||
if err != nil {
|
||||
denied := Denied()
|
||||
return nil, &denied
|
||||
}
|
||||
|
||||
// The worker, likewise.
|
||||
wq, denied := authorizeAs(tc, "worker-profiles", "w")
|
||||
if denied != nil {
|
||||
return nil, denied
|
||||
}
|
||||
wq.eq("email", strings.TrimSpace(in.WorkerEmail))
|
||||
var profileID string
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT w.id::text, w.full_name, w.email::text, nullif(w.krow_score, 0)
|
||||
FROM worker_profiles w
|
||||
WHERE `+wq.clause()+`
|
||||
LIMIT 1`, wq.args...,
|
||||
).Scan(&profileID, &plan.workerName, &plan.workerEmail, &plan.matchScore); err != nil {
|
||||
denied := Denied()
|
||||
return nil, &denied
|
||||
}
|
||||
plan.workerProfileID = &profileID
|
||||
if strings.TrimSpace(plan.workerName) == "" {
|
||||
plan.workerName = plan.workerEmail
|
||||
}
|
||||
|
||||
// Clashes: active assignments overlapping the window. Counted in SQL —
|
||||
// fetching the person's roster and comparing in Go would be the same
|
||||
// post-filter I2 forbids, and would read rows this call has no reason to.
|
||||
if err := db.QueryRow(ctx, `
|
||||
SELECT count(*) FROM assignments
|
||||
WHERE org_id = $1::uuid AND worker_email = $2 AND status = 'active'
|
||||
AND tstzrange(starts_at, coalesce(ends_at, 'infinity'::timestamptz))
|
||||
&& tstzrange($3, coalesce($4::timestamptz, 'infinity'::timestamptz))`,
|
||||
tc.OrgID(), plan.workerEmail, starts, nullableTime(ends),
|
||||
).Scan(&plan.clashes); err != nil {
|
||||
failed := Failf(CodeFailed, "the worker's existing assignments could not be read")
|
||||
return nil, &failed
|
||||
}
|
||||
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
/* ── Shared helpers ─────────────────────────────────────────────────────── */
|
||||
|
||||
// decodeWindow parses a caller-supplied period.
|
||||
//
|
||||
// RFC 3339 only, and validated here rather than at the database. A timestamp
|
||||
// that reaches SQL as an uninterpretable string is a constraint violation
|
||||
// wearing the costume of a tool failure, and the model cannot correct what it
|
||||
// cannot read.
|
||||
func decodeWindow(startsAt, endsAt string) (time.Time, *time.Time, *Result) {
|
||||
starts, err := time.Parse(time.RFC3339, strings.TrimSpace(startsAt))
|
||||
if err != nil {
|
||||
bad := Failf(CodeInvalidInput,
|
||||
"starts_at must be an RFC 3339 timestamp, for example 2026-09-12T18:00:00Z")
|
||||
return time.Time{}, nil, &bad
|
||||
}
|
||||
if strings.TrimSpace(endsAt) == "" {
|
||||
return starts, nil, nil
|
||||
}
|
||||
ends, err := time.Parse(time.RFC3339, strings.TrimSpace(endsAt))
|
||||
if err != nil {
|
||||
bad := Failf(CodeInvalidInput,
|
||||
"ends_at must be an RFC 3339 timestamp, for example 2026-09-12T23:00:00Z")
|
||||
return time.Time{}, nil, &bad
|
||||
}
|
||||
// The table has a CHECK for this. Caught here so the model gets a sentence
|
||||
// it can act on rather than a constraint name it cannot.
|
||||
if !ends.After(starts) {
|
||||
bad := Failf(CodeInvalidInput, "ends_at must be after starts_at")
|
||||
return time.Time{}, nil, &bad
|
||||
}
|
||||
return starts, &ends, nil
|
||||
}
|
||||
|
||||
// windowLabel renders a period the way a person reads one.
|
||||
func windowLabel(starts time.Time, ends *time.Time) string {
|
||||
if ends == nil {
|
||||
return starts.Format("Mon 2 Jan 2006, 15:04") + " onwards"
|
||||
}
|
||||
if ends.YearDay() == starts.YearDay() && ends.Year() == starts.Year() {
|
||||
return fmt.Sprintf("%s–%s",
|
||||
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("15:04"))
|
||||
}
|
||||
return fmt.Sprintf("%s – %s",
|
||||
starts.Format("Mon 2 Jan 2006, 15:04"), ends.Format("Mon 2 Jan 2006, 15:04"))
|
||||
}
|
||||
|
||||
func nullableTime(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return *t
|
||||
}
|
||||
|
||||
func plural(n int, one, many string) string {
|
||||
if n == 1 {
|
||||
return fmt.Sprintf("1 %s", one)
|
||||
}
|
||||
return fmt.Sprintf("%d %s", n, many)
|
||||
}
|
||||
Reference in New Issue
Block a user