Phase 7: notify and send via Apprise (Email + WhatsApp)
The morning briefs go out through the house Apprise API on `.35:8210`: one brief per boy and one combined brief to Jo and Sam (FR-9, FR-14). The service names an Apprise tag (`harry_email`, `joanna_whatsapp`, ...) and never holds an address, a phone number or an SMTP password (AGENTS.md §10 rule 3). - internal/apprise: keyed and stateless client, tag filtering, and error redaction so nothing sensitive reaches a log, a row or a screen. - internal/brief: builds the per-boy and the combined brief, delivers on email and WhatsApp, records every attempt in `notifications` (migration 004 adds scope, channel, tag, ok and error) and fails loudly. - Send panel (§5 screen 4): recipients, channels, an exact preview, Send now, plus the Send action on a stored draft (FR-10). The dashboard shows a Morning briefs banner and the Sends list. - POST /notify/morning: the bearer-token endpoint the 6:00am n8n schedule calls. 207 when a channel or a scan failed; `dry_run=1` checks the wiring without messaging the family. - scripts/morning-brief.sh: scan both children, then send. It is the n8n job; deploy/n8n-morning-brief.json is the ready-to-import workflow (06:00 daily). - FR-12: a failed scan is never "nothing due" -- the brief says so, and the run exits non-zero. A failed delivery is recorded with no sent_at. - The long no-due-date tail is capped in the sent brief so it stays brief (§10 rule 6); overdue work is never capped. Verified live: 8 deliveries recorded (4 email, 4 WhatsApp) with HTTP 200 and sent_at, and the endpoint answers 401/200/207 as documented. Evidence in docs/phase-7-notify-notes.md. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -34,6 +34,7 @@
|
||||
</header>
|
||||
|
||||
{{if .ErrMsg}}<p class="flash flash--error" role="alert">{{.ErrMsg}}</p>{{end}}
|
||||
{{if .Flash}}<p class="flash flash--{{$.FlashLevel}}" role="status">{{.Flash}}</p>{{end}}
|
||||
|
||||
<div hx-get="/partials/scan-status" hx-trigger="every 60s" hx-swap="outerHTML">
|
||||
{{template "scan-status.html" .Scan}}
|
||||
@@ -41,6 +42,22 @@
|
||||
|
||||
{{template "calendar-status.html" .Calendar}}
|
||||
|
||||
<section class="scan scan--{{.Notify.Level}}" aria-labelledby="notify-title">
|
||||
<div class="scan__head">
|
||||
<h2 id="notify-title" class="heading-2">Morning briefs</h2>
|
||||
<span class="category-pill scan__pill">
|
||||
{{if eq .Notify.Level "ok"}}sending
|
||||
{{else if eq .Notify.Level "error"}}failed
|
||||
{{else if eq .Notify.Level "warn"}}attention
|
||||
{{else}}off{{end}}
|
||||
</span>
|
||||
</div>
|
||||
<p class="scan__message">{{.Notify.Message}}</p>
|
||||
{{if or (gt .Notify.Sent 0) (gt .Notify.Failed 0)}}
|
||||
<p class="scan__meta">{{.Notify.Sent}} delivered · {{.Notify.Failed}} failed{{if .Notify.LastAt}} · last attempt {{.Notify.LastAt}}{{end}}</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{if .Tabs}}
|
||||
<nav class="child-tabs" aria-label="Children">
|
||||
{{range .Tabs}}
|
||||
@@ -126,26 +143,91 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Summaries}}
|
||||
<section class="section" aria-labelledby="summaries-title">
|
||||
{{if .Send.Scopes}}
|
||||
<section class="section" id="send-panel" aria-labelledby="send-title">
|
||||
<div class="section__head">
|
||||
<h2 id="summaries-title" class="heading-2">Briefs</h2>
|
||||
<h2 id="send-title" class="heading-2">Send a brief</h2>
|
||||
<span class="category-pill">Apprise</span>
|
||||
</div>
|
||||
{{if not .Send.Enabled}}
|
||||
<p class="flash flash--error" role="alert">
|
||||
NOTIFICATIONS NOT CONFIGURED — nothing can be sent. Set {{range $i, $m := .Send.Missing}}{{if $i}}, {{end}}{{$m}}{{end}}.
|
||||
</p>
|
||||
{{else}}
|
||||
{{range .Send.Scopes}}
|
||||
<details class="send-card">
|
||||
<summary class="send-card__summary">{{.Label}}</summary>
|
||||
<form method="post" action="/summaries/send" class="send-card__form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="scope" value="{{.Key}}">
|
||||
<fieldset class="field">
|
||||
<legend class="field__label">Recipients</legend>
|
||||
<div class="check-row">
|
||||
{{range .Recipients}}
|
||||
<label class="check"><input type="checkbox" name="recipient" value="{{.Name}}"{{if .Checked}} checked{{end}}> {{.Name}}</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="field">
|
||||
<legend class="field__label">Channels</legend>
|
||||
<div class="check-row">
|
||||
{{range $.Send.Channels}}
|
||||
<label class="check"><input type="checkbox" name="channel" value="{{.Name}}"{{if .Checked}} checked{{end}}> {{.Label}}</label>
|
||||
{{end}}
|
||||
</div>
|
||||
</fieldset>
|
||||
{{if .Warning}}<p class="flash flash--error" role="alert">{{.Warning}}</p>{{end}}
|
||||
<div class="send-card__actions">
|
||||
<button class="button-utility" type="submit">Send now</button>
|
||||
<span class="send-card__hint">The preview is exactly what is sent. Overdue first, lists only.</span>
|
||||
</div>
|
||||
<details class="brief-wrap" open>
|
||||
<summary class="send-card__summary">preview ({{len .Preview}} bytes)</summary>
|
||||
<pre class="brief">{{.Preview}}</pre>
|
||||
</details>
|
||||
</form>
|
||||
</details>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .Summaries}}
|
||||
<section class="section" aria-labelledby="sends-title">
|
||||
<div class="section__head">
|
||||
<h2 id="sends-title" class="heading-2">Sends</h2>
|
||||
<span class="category-pill"><span class="dot dot--faint"></span>{{len .Summaries}}</span>
|
||||
</div>
|
||||
<ul class="item-list">
|
||||
{{range .Summaries}}
|
||||
<li class="item" id="summary-{{.ID}}">
|
||||
<li class="item{{if .Failed}} item--overdue{{end}}" id="summary-{{.ID}}">
|
||||
<div class="item__body">
|
||||
<p class="item__title">{{.Recipient}} · {{.Sent}}</p>
|
||||
<p class="item__meta"><span class="item__subject">built {{.Created}}</span></p>
|
||||
<pre class="brief">{{.Text}}</pre>
|
||||
<p class="item__title">{{.Recipient}} · {{.Sent}}{{if .Channel}} · {{.Channel}}{{end}}</p>
|
||||
<p class="item__meta">
|
||||
<span class="tag tag--type">{{.Scope}}</span>
|
||||
{{if .Tag}}<span class="item__subject">{{.Tag}}</span>{{end}}
|
||||
{{if .Draft}}<span class="item__subject">built {{.Created}}</span>{{end}}
|
||||
</p>
|
||||
{{if .Error}}<p class="flash flash--error" role="alert">{{.Error}}</p>{{end}}
|
||||
<details class="brief-wrap">
|
||||
<summary class="send-card__summary">brief text ({{len .Text}} bytes)</summary>
|
||||
<pre class="brief">{{.Text}}</pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="item__actions">
|
||||
{{if .Draft}}
|
||||
<form method="post" action="/summaries/{{.ID}}/send">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="button-utility" type="submit">Send</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="/summaries/{{.ID}}/delete"
|
||||
hx-post="/summaries/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
|
||||
hx-confirm="Delete this brief and its delivery record?">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="button-utility button-utility--danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
<form class="item__actions" method="post" action="/summaries/{{.ID}}/delete"
|
||||
hx-post="/summaries/{{.ID}}/delete" hx-target="closest .item" hx-swap="delete"
|
||||
hx-confirm="Delete this brief?">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<button class="button-utility button-utility--danger" type="submit">Delete</button>
|
||||
</form>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user