mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-11-02 09:23:59 +00:00
b8b610812c
* feat(users): users.timezone column, internal/usertz and the conditional set-if-unset write (TASK-4101) Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * feat(auth): timezone on /auth/me and the session user, PATCH with if_unset, tz-mismatch dismissal keys (TASK-4101) Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * fix(server): compute overdue, standup and stamped dates in the viewer's zone, UTC when unset (TASK-4101) Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * test(server): regenerate the cloud contract goldens for the additive timezone field (TASK-4101) Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * fix(store): add the Postgres twin of the users.timezone migration (TASK-4101) pgmigrations has its own numbering; 160 alone left the column missing on Postgres, and make test-pg failed on 'column "timezone" does not exist'. Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * feat(store): NUL invariant triggers for users.timezone (TASK-4101) The census exists so no single validator is the only guard (ruling 1431c980 item 1): protect the column with its own post-160 trigger file instead of excluding it. Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * fix(server): decode a dismissal key once, and test the viewer zone through /me/needs (TASK-4101) A double-encoded %252F reached the handler already decoded once (net/url keeps no RawPath for it), and the second PathUnescape read it as a slash. Unescape only when chi routed on RawPath. Adds the unescape cases, the too_many_dismissals code assertion, an HTTP-level test that a zone ahead of UTC makes an item overdue on /me/needs, and the api.md notes for the cap and the additive dashboard/standup fields (ruling 1431c980 items 2-5). Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v * fix(server): the standup test expects the UTC day, and the zone note names no PR2 surface (TASK-4101) The standup date is the viewer's zone, UTC when unset; the test still compared against the host's local day and failed on any non-UTC host when the two dates differ. The note pointed at the web settings and `pad auth timezone`, which land with PR2 (ruling 00877b60). Claude-Session: https://claude.ai/code/session_01Pa22hHHb1atXYf1JmBYE1v
311 lines
10 KiB
Go
311 lines
10 KiB
Go
// Package needs is the cross-workspace "needs you" inbox's vocabulary and its
|
|
// routing rule (TASK-3920, IDEA-3906 half A unit A4). It holds no store and no
|
|
// HTTP: the server loads candidates, asks Resolve who each one is for, and
|
|
// renders the rows. Keeping the rule pure is what lets one table test pin its
|
|
// order.
|
|
package needs
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// The four routing rules, in the order Resolve tries them.
|
|
const (
|
|
RuleAsked = "asked"
|
|
RuleMentioned = "mentioned"
|
|
RuleAssigned = "assigned"
|
|
RuleAdminFallback = "admin_fallback"
|
|
)
|
|
|
|
// The kinds a Because entry can have.
|
|
const (
|
|
BecauseHumanTask = "human_task"
|
|
BecauseNeedsHuman = "needs_human"
|
|
BecauseMention = "mention"
|
|
)
|
|
|
|
// Mention is one open @mention of a user on an item.
|
|
type Mention struct {
|
|
UserID string
|
|
CommentID string
|
|
By string
|
|
At time.Time
|
|
}
|
|
|
|
// Candidate is one open item the server found waiting on someone, with the
|
|
// facts the rules read. The server has already dropped terminal items.
|
|
type Candidate struct {
|
|
ItemID string
|
|
CollectionID string
|
|
// AwaitsHuman: the item is in an awaits_human collection (an ask).
|
|
AwaitsHuman bool
|
|
// AssigneeUserID is the assigned PERSON; a role-only assignment is "" and
|
|
// falls through every rule that needs one (TASK-3615's ruling).
|
|
AssigneeUserID string
|
|
// AssignedAt is when the item was last assigned to that person, or the
|
|
// item's creation when no assignment was recorded.
|
|
AssignedAt time.Time
|
|
CreatedAt time.Time
|
|
Mentions []Mention
|
|
// NeedsHuman, when set, is when the attention set's needs_human answer was
|
|
// written.
|
|
NeedsHuman *time.Time
|
|
}
|
|
|
|
// Eligibility answers whether a user may be routed an item: a live human who
|
|
// may see it. Implementations cache; Resolve calls it freely.
|
|
type Eligibility interface {
|
|
Eligible(userID, collectionID, itemID string) bool
|
|
}
|
|
|
|
// Because is one open claim on an item, shown to the person it is routed to.
|
|
type Because struct {
|
|
Kind string `json:"kind"`
|
|
At time.Time `json:"at"`
|
|
By string `json:"by,omitempty"`
|
|
CommentID string `json:"comment_id,omitempty"`
|
|
}
|
|
|
|
// Why says which rule routed the item to the viewer.
|
|
type Why struct {
|
|
Rule string `json:"rule"`
|
|
Text string `json:"text"`
|
|
By string `json:"by,omitempty"`
|
|
At *time.Time `json:"at,omitempty"`
|
|
CommentID string `json:"comment_id,omitempty"`
|
|
}
|
|
|
|
// Routing is Resolve's answer for one candidate.
|
|
type Routing struct {
|
|
Rule string
|
|
// People are the users the winning rule routes to, in rule order. Only the
|
|
// winning rule's people are returned (lead ruling A1: exclusive).
|
|
People []string
|
|
Why Why
|
|
Because []Because
|
|
WaitingSince time.Time
|
|
|
|
// byUser holds, for the mentioned rule, each routed person's own view: the
|
|
// row for one person is computed from that person's mentions only.
|
|
byUser map[string]Routing
|
|
}
|
|
|
|
// For returns the routing as the given user sees it. Under the mentioned rule
|
|
// that is built from the user's own mentions alone, so nothing of another
|
|
// person's mention (its time, author or comment id) reaches them. Under the
|
|
// other rules the routing is the same for every routed person.
|
|
func (r Routing) For(userID string) Routing {
|
|
if v, ok := r.byUser[userID]; ok {
|
|
return v
|
|
}
|
|
return r
|
|
}
|
|
|
|
// Includes reports whether the user is among the routed people.
|
|
func (r Routing) Includes(userID string) bool {
|
|
for _, p := range r.People {
|
|
if p == userID {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Resolve applies the rules in strict, exclusive order and returns the first
|
|
// that yields at least one eligible person, or ok=false when none does (the
|
|
// item is dropped: never "everyone"):
|
|
//
|
|
// 1. asked: the item is in an awaits_human collection and its assigned
|
|
// person is eligible;
|
|
// 2. mentioned: the eligible people with an open @mention, by mention time;
|
|
// 3. assigned: the assigned person, if eligible;
|
|
// 4. admin_fallback: the eligible workspace owners.
|
|
//
|
|
// WaitingSince is the earliest open claim: the winning rule's own claim (the
|
|
// item's creation for asked and the fallback, the mention for mentioned, the
|
|
// latest assignment for assigned) and the needs_human answer when the item
|
|
// carries one. Why.Text comes from the winning rule alone and never names a
|
|
// person the rules skipped.
|
|
func Resolve(c Candidate, owners []string, elig Eligibility) (Routing, bool) {
|
|
ok := func(id string) bool { return id != "" && elig.Eligible(id, c.CollectionID, c.ItemID) }
|
|
r := Routing{}
|
|
var claim time.Time
|
|
switch {
|
|
case c.AwaitsHuman && ok(c.AssigneeUserID):
|
|
r.Rule, r.People, claim = RuleAsked, []string{c.AssigneeUserID}, c.CreatedAt
|
|
r.Because = append(r.Because, Because{Kind: BecauseHumanTask, At: c.CreatedAt})
|
|
r.Why = Why{Text: "An ask of you: it is assigned to you in a collection of asks."}
|
|
case len(eligibleMentions(c, ok)) > 0:
|
|
ms := eligibleMentions(c, ok)
|
|
seen := map[string]bool{}
|
|
for _, m := range ms {
|
|
if !seen[m.UserID] {
|
|
seen[m.UserID] = true
|
|
r.People = append(r.People, m.UserID)
|
|
}
|
|
}
|
|
r.Rule = RuleMentioned
|
|
r.byUser = map[string]Routing{}
|
|
for _, uid := range r.People {
|
|
own := Routing{Rule: RuleMentioned, People: r.People}
|
|
var first *Mention
|
|
for i := range ms {
|
|
m := ms[i]
|
|
if m.UserID != uid {
|
|
continue
|
|
}
|
|
own.Because = append(own.Because, Because{Kind: BecauseMention, At: m.At, By: m.By, CommentID: m.CommentID})
|
|
if first == nil || m.At.Before(first.At) {
|
|
first = &ms[i]
|
|
}
|
|
}
|
|
at := first.At
|
|
own.Why = Why{Text: "You were @-mentioned and have not replied.", By: first.By, At: &at, CommentID: first.CommentID}
|
|
own.WaitingSince = first.At
|
|
r.byUser[uid] = own
|
|
}
|
|
// The shared claim is the earliest mention of anyone; a row never
|
|
// shows it, only each person's own (see For).
|
|
claim = ms[0].At
|
|
for _, m := range ms {
|
|
if m.At.Before(claim) {
|
|
claim = m.At
|
|
}
|
|
}
|
|
case ok(c.AssigneeUserID):
|
|
r.Rule, r.People, claim = RuleAssigned, []string{c.AssigneeUserID}, c.AssignedAt
|
|
r.Why = Why{Text: "It is assigned to you."}
|
|
default:
|
|
for _, o := range owners {
|
|
if ok(o) {
|
|
r.People = append(r.People, o)
|
|
}
|
|
}
|
|
if len(r.People) == 0 {
|
|
return Routing{}, false
|
|
}
|
|
r.Rule, claim = RuleAdminFallback, c.CreatedAt
|
|
r.Why = Why{Text: "Nobody else can take it, so it falls to the workspace owners."}
|
|
}
|
|
finish := func(x *Routing, claim time.Time) {
|
|
x.Why.Rule = x.Rule
|
|
if c.NeedsHuman != nil {
|
|
x.Because = append(x.Because, Because{Kind: BecauseNeedsHuman, At: *c.NeedsHuman})
|
|
if c.NeedsHuman.Before(claim) {
|
|
claim = *c.NeedsHuman
|
|
}
|
|
}
|
|
x.WaitingSince = claim
|
|
if x.Why.At == nil {
|
|
at := claim
|
|
x.Why.At = &at
|
|
}
|
|
}
|
|
for uid, own := range r.byUser {
|
|
finish(&own, own.WaitingSince)
|
|
r.byUser[uid] = own
|
|
}
|
|
finish(&r, claim)
|
|
return r, true
|
|
}
|
|
|
|
// eligibleMentions are the open mentions of eligible users, by time then user.
|
|
func eligibleMentions(c Candidate, ok func(string) bool) []Mention {
|
|
var out []Mention
|
|
for _, m := range c.Mentions {
|
|
if ok(m.UserID) {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
// The server hands mentions ordered; keep that order stable.
|
|
return out
|
|
}
|
|
|
|
// Score is one scorer's verdict on a row. Value is 0-100.
|
|
type Score struct {
|
|
Value int `json:"value"`
|
|
Hint string `json:"hint,omitempty"`
|
|
Why []string `json:"why,omitempty"`
|
|
}
|
|
|
|
// Viewer is who the rows are scored for.
|
|
type Viewer struct {
|
|
UserID string
|
|
AuthIsBearer bool
|
|
// Now is the clock a scorer measures age and deadlines against: the
|
|
// request's as_of, so every page of one listing scores at one instant.
|
|
// The zero value means the wall clock.
|
|
Now time.Time
|
|
// Loc is the viewer's zone, the calendar a deadline's "today" is read in.
|
|
// Nil means UTC.
|
|
Loc *time.Location
|
|
}
|
|
|
|
// Scope is what the viewer may see in one workspace, in the shape the stores
|
|
// take: Unrestricted, or the collections and items listed (a nil list arrives
|
|
// as an empty one). A scorer counts only under it.
|
|
type Scope struct {
|
|
WorkspaceID string
|
|
Unrestricted bool
|
|
CollectionIDs []string
|
|
ItemIDs []string
|
|
}
|
|
|
|
// Scorer fills a score on rows (the importance and simplicity scorers of
|
|
// TASK-3959 are the registered ones). It is called once per workspace, after
|
|
// visibility, routing and snooze and before sort and pagination, and must not
|
|
// call a provider synchronously. degraded
|
|
// reports that its source failed and the scores came back partial or empty:
|
|
// the server flags that workspace only.
|
|
type Scorer interface {
|
|
Name() string // "importance" or "simplicity"
|
|
Score(ctx context.Context, viewer Viewer, scope Scope, rows []*Row) (degraded bool, err error)
|
|
}
|
|
|
|
// WorkspaceLabel names the workspace a row lives in. It is built only from
|
|
// workspaces the caller was admitted to.
|
|
type WorkspaceLabel struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
OwnerUsername string `json:"owner_username"`
|
|
}
|
|
|
|
// ItemInfo is the item a row is about.
|
|
type ItemInfo struct {
|
|
Ref string `json:"ref"`
|
|
Title string `json:"title"`
|
|
Collection string `json:"collection"`
|
|
CollectionSlug string `json:"collection_slug"`
|
|
Status string `json:"status,omitempty"`
|
|
Priority string `json:"priority,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// Row is one item that needs the viewer.
|
|
type Row struct {
|
|
Workspace WorkspaceLabel `json:"workspace"`
|
|
Item ItemInfo `json:"item"`
|
|
Why Why `json:"why"`
|
|
Because []Because `json:"because"`
|
|
NeedsHuman *time.Time `json:"needs_human,omitempty"`
|
|
// WaitingSince is the earliest open claim; sort=age orders by it.
|
|
WaitingSince time.Time `json:"waiting_since"`
|
|
// Marker is the item's state now, for the client to send back when it
|
|
// snoozes the row, so a comment that lands meanwhile is not swallowed.
|
|
Marker *models.NeedsSnoozeMarker `json:"marker,omitempty"`
|
|
Snooze *models.NeedsSnooze `json:"snooze,omitempty"`
|
|
Importance *Score `json:"importance,omitempty"`
|
|
Simplicity *Score `json:"simplicity,omitempty"`
|
|
|
|
// Internal, never serialized.
|
|
ItemID string `json:"-"`
|
|
WorkspaceID string `json:"-"`
|
|
// Held: the viewer's snooze on the item holds now.
|
|
Held bool `json:"-"`
|
|
}
|