package notify
// Workflows returns the definitions quarry provisions at startup (RFC
// 0011 §7). These are the same six workflows docs/running.md used to
// ask an operator to `curl` by hand — this is now the one source,
// created by EnsureWorkflow rather than typed.
//
// RFC 0011 §6's routing table is spelled here and nowhere else:
//
// quarry-mention in_app -> push (immediate)
// quarry-forum-reply in_app -> push (immediate)
// quarry-landing in_app -> push (immediate)
// quarry-activity digest 15 min -> in_app
// quarry-error/slow in_app (admins; unchanged)
//
// IMPORTANT for an EXISTING forge: EnsureWorkflow upserts with
// notify's `onConflict=keep`, which never rewrites a workflow that is
// already there (an operator may have edited its copy). So a forge
// provisioned before this phase keeps its single-in_app definitions:
// the push and digest steps below reach it only after the operator
// deletes those workflows in notify's console once and lets the next
// startup recreate them.
func Workflows() []WorkflowDef {
// Control values are notify's own per-step-type shapes, read off
// internal/steptype (channels.go for in_app/push, sleep.go for
// digest). They are typed map[string]any rather than map[string]string
// because a digest's `amount` is a NUMBER on notify's side: a
// quoted "15" fails its json.Unmarshal into int64 and the step
// never compiles.
// The redirect is what makes an inbox row a link. notify stores it
// on the message as extras.redirect.url, which is exactly where
// Message.Link reads from, and the inbox renders a row with no link
// as dead text — eight rows of "something happened" leading
// nowhere. Every trigger quarry sends carries payload.link for this
// reason; the push step has always used it.
inApp := func(id, subject, body string) WorkflowStep {
return WorkflowStep{
StepID: id,
Type: "in_app",
ControlValues: map[string]any{
"subject": subject,
"body": body,
"redirect": map[string]any{"url": "{{payload.link}}"},
},
}
}
// push carries the link in data_json, which notify's push step
// validates as a JSON object of STRING values (steptype.push) and
// passes to the browser as the notification's data — where sw.js
// reads data.url to decide what a click opens.
push := func(id, title, body string) WorkflowStep {
return WorkflowStep{
StepID: id,
Type: "push",
ControlValues: map[string]any{
"title": title,
"body": body,
"data_json": `{"url":"{{payload.link}}"}`,
},
}
}
email := func(id, subject, body string) WorkflowStep {
return WorkflowStep{
StepID: id,
Type: "email",
ControlValues: map[string]any{
"subject": subject,
"body": body,
},
}
}
return []WorkflowDef{
{
// An invite goes to an ADDRESS: the person has no account, so
// no subscriber, and no in-app inbox to land in.
Name: "quarry-invite",
TriggerIdentifier: "quarry-invite",
Steps: []WorkflowStep{
email("S1", "{{payload.from}} invited you to quarry",
"You have been invited to quarry as {{payload.name}}. Set your password here: {{payload.link}}"),
},
},
{
// What changed on the forge: one inbox item and a push per
// announced changelog entry, to everyone with an account.
Name: "quarry-changelog",
TriggerIdentifier: "quarry-changelog",
Steps: []WorkflowStep{
inApp("S1", "What changed in quarry: {{payload.title}}",
"{{payload.from}} published a changelog entry — {{payload.link}}"),
push("S2", "What changed in quarry", "{{payload.title}}"),
},
},
{
Name: "quarry-mention",
TriggerIdentifier: "quarry-mention",
Steps: []WorkflowStep{
inApp("S1", "{{payload.from}} mentioned you in {{payload.repo}}",
"{{payload.repo}} — {{payload.text}}"),
push("S2", "{{payload.from}} mentioned you",
"{{payload.repo}} — {{payload.text}}"),
},
},
{
Name: "quarry-forum-reply",
TriggerIdentifier: "quarry-forum-reply",
Steps: []WorkflowStep{
inApp("S1", "{{payload.from}} replied in {{payload.title}}",
"{{payload.repo}} — {{payload.text}}"),
push("S2", "{{payload.from}} replied in {{payload.title}}",
"{{payload.repo}} — {{payload.text}}"),
},
},
{
// The one workflow with a digest: activity is a stream of
// small things, and a phone that buzzes per check-in is a
// phone somebody turns off. Fifteen minutes is RFC 0011 §6's
// window; the digest runs BEFORE the in_app step, so what
// lands in the inbox is the batch rather than each event.
Name: "quarry-activity",
TriggerIdentifier: "quarry-activity",
Steps: []WorkflowStep{
{
StepID: "S1",
Type: "digest",
ControlValues: map[string]any{
"type": "regular",
"amount": 15,
"unit": "minutes",
},
},
inApp("S2", "{{payload.summary}}", "{{payload.repo}} — by {{payload.actor}}"),
},
},
{
Name: "quarry-landing",
TriggerIdentifier: "quarry-landing",
Steps: []WorkflowStep{
inApp("S1", "{{payload.summary}}", "{{payload.repo}} — {{payload.link}}"),
push("S2", "{{payload.summary}}", "{{payload.repo}}"),
},
},
{
Name: "quarry-error",
TriggerIdentifier: "quarry-error",
Steps: []WorkflowStep{inApp("S1",
"quarry error on {{payload.surface}}",
"{{payload.path}} — {{payload.summary}}",
)},
},
{
Name: "quarry-slow",
TriggerIdentifier: "quarry-slow",
Steps: []WorkflowStep{inApp("S1",
"slow render: {{payload.path}}",
"{{payload.summary}}",
)},
},
}
}