Skip to content
quarry Artifact [d1067ab2a5]
Login

Artifact [d1067ab2a5]

Artifact d1067ab2a50fbd3840da9901eb9afd10270317fcc8b1aed98140edbda703ee7c:


package notify

import (
	"strings"

	"craftdesign.group/quarry/internal/markup"
)

// 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 -> email (immediate; email accepts replies)
//	quarry-forum-reply  in_app -> push -> email (immediate; email accepts replies)
//	quarry-landing      in_app -> push -> email (immediate; email accepts replies)
//	quarry-activity     digest 2 h -> in_app -> email
//	quarry-error/slow   in_app (admins; unchanged)
//	quarry-ops          in_app -> push (admins; the host's alarms, scripts/quarry-alert.sh)
//
// Email is OFF by default for every family: notify reads an absent
// preference as enabled, so the forge writes the explicit "no" for each
// subscriber who has not chosen (emailOffByDefault in cmd/quarry). The
// step exists so the choice exists; nobody is mailed for not having
// made it.
//
// 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{
				// BOTH spellings, deliberately. notify's push editor
				// authors "title", but the envelope its delivery
				// renders from (runenv.StepControls) carries only
				// "subject" — so a push authored the documented way
				// arrives with an empty title and the service worker
				// falls back to the word "quarry" for every
				// notification (notify#334). "subject" is what renders
				// today; "title" is what the editor will show an
				// operator, and what will keep working when the two
				// spellings meet.
				"title":     title,
				"subject":   title,
				"body":      body,
				"data_json": `{"url":"{{payload.link}}"}`,
			},
		}
	}
	// rich is an email's HTML alternative for a payload whose html field
	// is markdown the forge rendered (api.withMailHTML). notify inserts it
	// as sent, and an event without the field renders it empty, which
	// notify sends as text alone: a trigger that omits html is unchanged.
	const rich = `{{raw "payload.html"}}`
	// email is plain text, one link, and — where the notification is
	// something a person answers — accepts replies: notify then sends it
	// with a tokenised Reply-To and hands the answer back on the
	// delivery stream (RFC 0014 Phase 1). A digest is not answered, so
	// the activity email does not opt in.
	// activityHTML is the digest's card, built by the same markup.EmailDocument
	// as every other quarry email. The batch is notify's to loop over, so
	// the body and the lines naming the forge are Handlebars; the three
	// href values go through html/template's URL filter, which would
	// mangle braces, so they are placeholders swapped in afterwards.
	activityHTML := func() string {
		const link, settings, home = "https://q.invalid/link", "https://q.invalid/settings", "https://q.invalid/home"
		h, _ := markup.EmailDocument(markup.EmailDoc{
			Title:   "quarry activity",
			Repo:    "{{payload.repo}}",
			Surface: "activity",
			Context: "{{#if digest.events.[1]}}<strong>{{digest.count}} updates</strong> in <strong>{{payload.repo}}</strong>" +
				"{{else}}<strong>{{payload.actor}}</strong> in <strong>{{payload.repo}}</strong>{{/if}}",
			Body: "{{#if digest.events.[1]}}<ul style=\"margin:0 0 14px;padding-left:22px;\">" +
				"{{#each digest.events}}<li style=\"margin:0 0 6px;\"><strong>{{this.actor}}</strong> {{this.summary}} " +
				"<a class=\"q-a\" href=\"{{this.link}}\" style=\"color:#a04412;\">open</a></li>{{/each}}</ul>" +
				"{{else}}<p style=\"margin:0 0 12px;\">{{payload.summary}}</p>{{/if}}",
			Open: "Open the activity", OpenURL: link,
			Why:         "You're getting this because you follow activity in {{payload.repo}}.",
			SettingsURL: settings, HomeURL: home, HomeName: "{{payload.host}}",
		})
		return strings.NewReplacer(
			link, "{{payload.link}}",
			settings, "{{payload.origin}}/me#notifications",
			home, "{{payload.origin}}",
		).Replace(h)
	}
	email := func(id, subject, body string, reply bool, html ...string) WorkflowStep {
		cv := map[string]any{"subject": subject, "body": body}
		if len(html) > 0 {
			cv["htmlBody"] = html[0]
		}
		if reply {
			cv["reply"] = true
		}
		return WorkflowStep{StepID: id, Type: "email", ControlValues: cv}
	}
	return []WorkflowDef{
		{
			// 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}}"),
				email("S3", "{{payload.from}} mentioned you in {{payload.repo}}",
					"{{payload.from}} wrote:\n\n{{payload.text}}\n\n{{payload.link}}", true, rich),
			},
		},
		{
			// The author's copy of a decision. The reason is the whole
			// payload: it already exists in the public modlog, where it
			// is addressed to everybody and therefore to nobody. This
			// is where it becomes a message to the person it is about.
			Name:              "quarry-moderation",
			TriggerIdentifier: "quarry-moderation",
			// IN-APP ONLY, deliberately. An email step must belong to a
			// preference family (EmailFamilies) or it is mail nobody can
			// turn off, and adding a fifth family is a change to the
			// notification matrix rather than to this workflow. The
			// defect being fixed is the missing INBOX row; email can
			// follow with the preference that makes it optional.
			Steps: []WorkflowStep{
				inApp("S1", "{{payload.title}}", "{{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}}"),
				email("S3", "{{payload.from}} replied in {{payload.title}}",
					"{{payload.from}} wrote:\n\n{{payload.text}}\n\n{{payload.link}}", true, rich),
			},
		},
		{
			// 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",
						// Two hours, which is the number somebody who
						// has not asked for anything should get: often
						// enough to be news, rare enough that a busy
						// repository does not become the reason people
						// mute the forge. It is the DEFAULT, not the
						// policy — a per-person window is notify#336.
						"amount": 2,
						"unit":   "hours",
						// Per REPOSITORY, not per person. Without it one
						// window batches every repository somebody can
						// see into a single row — "7 updates" across
						// four projects, which answers no question
						// anybody has.
						//
						// ROOTED at payload: notify resolves a
						// single-segment path to the empty string and
						// then merges every event into the base bucket,
						// silently. "repo" looks right, provisions
						// without complaint, and digests nothing.
						"digestKey": "payload.repo",
					},
				},
				// A digest is a COUNT and its parts; a single event is
				// just that event. Both arrive through this one step, so
				// the copy tells them apart rather than reading "1
				// updates" for the commonest case. events.[1] is the
				// only "more than one" test Handlebars gives us without
				// a helper.
				inApp("S2",
					"{{#if digest.events.[1]}}{{digest.count}} updates in {{payload.repo}}"+
						"{{else}}{{payload.summary}}{{/if}}",
					"{{#if digest.events.[1]}}{{#each digest.events}}{{this.actor}} {{this.summary}}\n{{/each}}"+
						"{{else}}{{payload.repo}} — by {{payload.actor}}{{/if}}"),
				// One line per event, each with its own link; a single
				// event reads as itself, as the inbox row does.
				email("S3",
					"{{#if digest.events.[1]}}{{digest.count}} updates in {{payload.repo}}"+
						"{{else}}{{payload.summary}}{{/if}}",
					"{{#if digest.events.[1]}}{{#each digest.events}}{{this.actor}} {{this.summary}}\n{{this.link}}\n\n{{/each}}"+
						"{{else}}{{payload.actor}} {{payload.summary}}\n\n{{payload.link}}{{/if}}", false, activityHTML()),
			},
		},
		{
			Name:              "quarry-landing",
			TriggerIdentifier: "quarry-landing",
			Steps: []WorkflowStep{
				inApp("S1", "{{payload.summary}}", "{{payload.repo}} — {{payload.link}}"),
				push("S2", "{{payload.summary}}", "{{payload.repo}}"),
				email("S3", "{{payload.summary}}",
					"{{payload.summary}} in {{payload.repo}}\n\n{{payload.link}}", true, rich),
			},
		},
		{
			Name:              "quarry-error",
			TriggerIdentifier: "quarry-error",
			Steps: []WorkflowStep{inApp("S1",
				"quarry error on {{payload.surface}}",
				"{{payload.path}} — {{payload.summary}}",
			)},
		},
		{
			// A push a person asks for, to find out whether push
			// reaches this device at all. Push ONLY: a test that also
			// wrote to the inbox would make the inbox worse every time
			// somebody checked their phone.
			Name:              "quarry-test",
			TriggerIdentifier: "quarry-test",
			Steps: []WorkflowStep{push("S1", "quarry",
				"Test notification — push reaches this device.")},
		},
		{
			// The host's own alarms, sent by scripts/quarry-alert.sh
			// rather than the forge: a deploy that failed or rolled
			// back, a forge unit that crashed, a deploy that never
			// finished. Addressed to the admins topic. Push, because
			// the point is to reach somebody who is not at the
			// terminal; no email, which would need a preference
			// family (EmailFamilies).
			Name:              "quarry-ops",
			TriggerIdentifier: "quarry-ops",
			Steps: []WorkflowStep{
				inApp("S1", "{{payload.summary}}", "{{payload.text}}"),
				push("S2", "{{payload.summary}}", "{{payload.text}}"),
			},
		},
		{
			Name:              "quarry-slow",
			TriggerIdentifier: "quarry-slow",
			Steps: []WorkflowStep{inApp("S1",
				"slow render: {{payload.path}}",
				"{{payload.summary}}",
			)},
		},
	}
}

// EmailFamilies are the workflows that carry an email step a person
// may switch on: the four families of quarry's preference matrix. Email
// is the one channel that is off until chosen, and this is the list the
// forge writes that choice against.
func EmailFamilies() []string {
	return []string{"quarry-mention", "quarry-forum-reply", "quarry-landing", "quarry-activity"}
}