Bring the checklist app under version control

The project has lived only as a folder on the Mac Studio and, briefly, as a
transfer tarball. This is the first commit: the Express server and its web
front end, the Capacitor iOS and Android wrappers, and the release and backup
scripts.

The repo is the source, not a backup of the machine. Left out on purpose, by
the .gitignore files that were already in the tree: node_modules, build output,
mobile/www (regenerated from server/public on every build), and server/data,
which holds the live checklists and belongs on the NAS rather than in git.

Every credential is excluded too — server/.env, the Play upload keystore and
its properties file, the App Store Connect key and ios-signing.config. The one
secret that was not in an ignored file was the NAS SSH password, hard-coded in
build-deploy.sh and grepped back out of it by backup-to-drive.sh. Both now read
it from the login keychain (service checklist-nas, account Christian), falling
back to NAS_PASS in the environment, so rotating it is one keychain entry
rather than a history rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Waters Church
2026-09-24 13:00:57 -04:00
co-authored by Claude Opus 5
commit 469db5bc60
177 changed files with 33247 additions and 0 deletions
+448
View File
@@ -0,0 +1,448 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Recurring Checklist — Deployment & Maintenance Guide</title>
<style>
:root {
--ink: #1a1a1a;
--muted: #555;
--accent: #2c5d9c;
--accent-2: #f1c40f;
--bg: #ffffff;
--code-bg: #f5f7fa;
--rule: #d9dde3;
}
* { box-sizing: border-box; }
html, body { background: var(--bg); color: var(--ink); }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.55;
font-size: 12pt;
margin: 0;
padding: 24pt 28pt;
max-width: 7.5in;
}
h1 {
font-size: 24pt;
margin: 0 0 4pt;
color: var(--ink);
}
h1 + .subtitle {
color: var(--muted);
margin: 0 0 18pt;
font-size: 12pt;
}
h2 {
font-size: 16pt;
margin: 28pt 0 8pt;
padding-bottom: 4pt;
border-bottom: 2px solid var(--accent);
color: var(--accent);
page-break-after: avoid;
}
h3 {
font-size: 13pt;
margin: 18pt 0 6pt;
color: var(--ink);
page-break-after: avoid;
}
h4 {
font-size: 12pt;
margin: 14pt 0 4pt;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
font-weight: 600;
}
p, ul, ol, table { margin: 6pt 0; }
ul, ol { padding-left: 22pt; }
li { margin-bottom: 3pt; }
code, pre {
font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
font-size: 10.5pt;
}
code {
background: var(--code-bg);
padding: 1pt 5pt;
border-radius: 3pt;
border: 1px solid var(--rule);
}
pre {
background: var(--code-bg);
border: 1px solid var(--rule);
border-radius: 4pt;
padding: 10pt 12pt;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
pre code { background: transparent; border: none; padding: 0; }
table {
border-collapse: collapse;
width: 100%;
margin: 8pt 0;
}
th, td {
border: 1px solid var(--rule);
padding: 6pt 9pt;
text-align: left;
vertical-align: top;
font-size: 11pt;
}
th {
background: var(--code-bg);
font-weight: 600;
}
hr {
border: none;
border-top: 1px solid var(--rule);
margin: 18pt 0;
}
.callout {
border-left: 4px solid var(--accent-2);
background: #fff8db;
padding: 8pt 12pt;
margin: 10pt 0;
border-radius: 0 4pt 4pt 0;
}
.callout.note { border-left-color: var(--accent); background: #eaf2fb; }
.callout.warn { border-left-color: #d04a4a; background: #fcecec; }
.callout strong { display: block; margin-bottom: 4pt; }
.toc {
background: var(--code-bg);
border: 1px solid var(--rule);
border-radius: 6pt;
padding: 12pt 18pt;
margin: 12pt 0 24pt;
}
.toc ol { margin: 0; padding-left: 18pt; }
.toc li { margin-bottom: 2pt; }
.toc a { color: var(--ink); text-decoration: none; }
.kicker {
display: inline-block;
background: var(--accent);
color: white;
padding: 2pt 8pt;
font-size: 10pt;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
border-radius: 3pt;
margin-bottom: 8pt;
}
.small { font-size: 10pt; color: var(--muted); }
@media print {
body { padding: 0.6in 0.6in; max-width: none; }
h2 { page-break-after: avoid; }
h3 { page-break-after: avoid; }
pre, table, .callout { page-break-inside: avoid; }
}
</style>
</head>
<body>
<span class="kicker">Internal documentation</span>
<h1>Recurring Checklist</h1>
<p class="subtitle">Deployment &amp; maintenance guide — self-hosted web app on Synology DSM.</p>
<div class="toc">
<strong>Contents</strong>
<ol>
<li><a href="#overview">Overview</a></li>
<li><a href="#environment">Environment summary</a></li>
<li><a href="#deploy">Initial deployment</a></li>
<li><a href="#updates">Pushing app updates</a></li>
<li><a href="#backup">Backups</a></li>
<li><a href="#dns">DDNS, reverse proxy &amp; HTTPS</a></li>
<li><a href="#troubleshoot">Troubleshooting</a></li>
<li><a href="#references">Reference</a></li>
</ol>
</div>
<h2 id="overview">1. Overview</h2>
<p>
The Recurring Checklist is a small Node.js web application that lives in a Docker container on the
Synology NAS. Multi-location operators (Woonsocket, North Attleboro, etc.) open it in a web browser,
identify themselves once per device, and tick items off through the week. Every Sunday at 3:00 PM the
server emails a digest of who completed what (and what was missed) to a single recipient, then resets
all checkboxes for the following week.
</p>
<h4>Architecture</h4>
<pre><code>Users (LAN or internet)
│
▼
https://check.watersmedia.org (Boba-Fett DSM, port 443)
│
│ reverse proxy
▼
http://10.1.1.1:3000 (Chronicles-NA DSM, Docker container)
│
▼
node:20-alpine + Express + node-cron + nodemailer
│
├── data volume → /volume1/docker/recurring-checklist/data/checklists.json
│ (backed up nightly to Google Drive via Hyper Backup)
│
└── Sunday 3:00 PM cron → Gmail SMTP → media@waterschurch.org</code></pre>
<h2 id="environment">2. Environment summary</h2>
<table>
<tr><th>Item</th><th>Value</th></tr>
<tr><td>App URL (external)</td><td><code>https://check.watersmedia.org</code></td></tr>
<tr><td>App URL (LAN, direct)</td><td><code>http://10.1.1.1:3000</code></td></tr>
<tr><td>Public TLS host</td><td>Boba-Fett (10.1.1.4), DSM reverse proxy + Let's Encrypt cert</td></tr>
<tr><td>Container host</td><td>Chronicles-NA (10.1.1.1), Container Manager project <code>recurring-checklist</code></td></tr>
<tr><td>Project folder on NAS</td><td><code>/volume1/docker/recurring-checklist/</code></td></tr>
<tr><td>Persisted data file</td><td><code>/volume1/docker/recurring-checklist/data/checklists.json</code></td></tr>
<tr><td>Backups</td><td>Hyper Backup → Google Drive (<code>recurring-checklist_server-backup/Chronicles-NA_1</code>), daily 23:30</td></tr>
<tr><td>Email digest schedule</td><td>Sunday 15:00 America/New_York</td></tr>
<tr><td>Email recipient</td><td><code>media@waterschurch.org</code></td></tr>
<tr><td>SMTP relay</td><td><code>smtp.gmail.com:587</code> (Google Workspace app password)</td></tr>
<tr><td>Admin password</td><td>Set in <code>.env</code> as <code>ADMIN_PASSWORD</code></td></tr>
</table>
<h2 id="deploy">3. Initial deployment</h2>
<p>The container was created using the following procedure. Use this section as the disaster-recovery /
rebuild reference.</p>
<h3>3.1 Prerequisites</h3>
<ul>
<li>Synology DSM 7.2 or newer on the host NAS (Chronicles-NA)</li>
<li>Container Manager package installed</li>
<li>SSH or File Station access to <code>/volume1/docker/</code></li>
<li>Google Workspace account with an App Password (for the digest sender)</li>
<li>The deployment tarball: <code>checklist-deploy.tgz</code> (produced from the project's <code>server/</code> directory)</li>
</ul>
<h3>3.2 Upload the bundle</h3>
<ol>
<li>Open File Station on the NAS.</li>
<li>Inside <code>/volume1/docker/</code>, create a folder named <code>recurring-checklist</code>.</li>
<li>Upload <code>checklist-deploy.tgz</code> into it.</li>
<li>Right-click → Extract → Extract here. Repeat once on the resulting <code>.tar</code> (Synology's
File Station unwraps in two passes).</li>
<li>Delete the <code>.tgz</code>, <code>.tar</code>, and the <code>PaxHeader</code> folder that gets produced. The
folder should be left with:
<pre><code>server.js public/ Dockerfile
package.json data/ docker-compose.yml
package-lock.json README.md .env .env.example .dockerignore .gitignore</code></pre>
</li>
</ol>
<h3>3.3 Configure environment</h3>
<p>The deployment ships with a working <code>.env</code> file. Verify the following keys are populated:</p>
<table>
<tr><th>Variable</th><th>Purpose</th></tr>
<tr><td><code>ADMIN_PASSWORD</code></td><td>Required for any list-management operation in the UI.</td></tr>
<tr><td><code>REPORT_EMAIL</code></td><td>Where the Sunday digest is delivered.</td></tr>
<tr><td><code>REPORT_CRON</code></td><td>cron schedule for the digest. Default <code>0 15 * * 0</code> (Sun 3:00 PM).</td></tr>
<tr><td><code>REPORT_TZ</code></td><td>IANA timezone for the cron (default <code>America/New_York</code>).</td></tr>
<tr><td><code>RESET_AFTER_DIGEST</code></td><td><code>true</code> = clear all checks once the digest is sent. <code>false</code> to disable.</td></tr>
<tr><td><code>SMTP_HOST</code></td><td><code>smtp.gmail.com</code></td></tr>
<tr><td><code>SMTP_PORT</code></td><td><code>587</code></td></tr>
<tr><td><code>SMTP_USER</code></td><td>Gmail / Google Workspace address that sends the digest.</td></tr>
<tr><td><code>SMTP_PASS</code></td><td>16-character app password from <code>myaccount.google.com/apppasswords</code>.</td></tr>
<tr><td><code>SMTP_FROM</code></td><td>Friendly <code>From:</code> header, e.g. <code>Recurring Checklist &lt;media@waterschurch.org&gt;</code></td></tr>
</table>
<div class="callout warn">
<strong>Security note</strong>
The <code>.env</code> file contains credentials. Do not place it in a publicly-shared folder. Hyper
Backup is encrypted in transit; client-side encryption is not enabled because it complicates restore.
</div>
<h3>3.4 Create the Container Manager project</h3>
<ol>
<li>Open Container Manager → <strong>Project</strong> → <strong>Create</strong>.</li>
<li><strong>Project name:</strong> <code>recurring-checklist</code></li>
<li><strong>Path:</strong> browse to <code>/volume1/docker/recurring-checklist</code> → Select.</li>
<li><strong>Source:</strong> "Use existing docker-compose.yml" (auto-selected). Click <strong>OK</strong>.</li>
<li>On the <em>Web portal settings</em> screen, leave the checkbox unchecked. Click <strong>Next</strong>.</li>
<li>Confirm the summary and click <strong>Done</strong>.</li>
</ol>
<p>Container Manager will build the image (~60–90 seconds on first run) and start the container. The
project should report <code>Exit Code: 0</code> at the end of the build log and the project status indicator
should turn green.</p>
<h3>3.5 Verify</h3>
<ol>
<li>From a browser on the LAN, open <code>http://10.1.1.1:3000</code>. The "Who are you?" prompt should appear.</li>
<li>Click <strong>Admin</strong> (top right) and authenticate with the value of <code>ADMIN_PASSWORD</code>.</li>
<li>Use <strong>Send Digest Now</strong> in the admin drawer to verify SMTP connectivity. A test email should arrive within a few seconds.</li>
</ol>
<h2 id="updates">4. Pushing app updates</h2>
<p>Updates are delivered as a refreshed deployment tarball. Application data is mounted from the host's
<code>data/</code> directory, so rebuilding the image preserves all checklists, locations, and check states.</p>
<h3>4.1 Receive the update</h3>
<p>The developer produces a fresh <code>checklist-deploy.tgz</code> from the latest project tree. It will
arrive as a single file (currently ~35 KB).</p>
<h3>4.2 Apply on the NAS</h3>
<ol>
<li>Open File Station → navigate to <code>/volume1/docker/recurring-checklist/</code>.</li>
<li>Upload the new <code>checklist-deploy.tgz</code> into this folder.</li>
<li>Right-click → Extract → Extract here. Choose <strong>Overwrite</strong> when prompted (this replaces
<code>server.js</code>, the <code>public/</code> folder, etc.; it does <em>not</em> touch <code>data/</code>).</li>
<li>Repeat the extract on the resulting <code>.tar</code>.</li>
<li>Delete the <code>.tgz</code>, <code>.tar</code>, and <code>PaxHeader</code> when done.</li>
</ol>
<h3>4.3 Rebuild the container</h3>
<ol>
<li>Open Container Manager → <strong>Project</strong> → click the <code>recurring-checklist</code> row.</li>
<li>Click <strong>Action</strong> → <strong>Build</strong> (rebuilds the image from the updated source).</li>
<li>Wait for the build log to finish with <code>Exit Code: 0</code>.</li>
<li>Click <strong>Action</strong> → <strong>Stop</strong>, then <strong>Action</strong> → <strong>Start</strong> if the build did not auto-restart.</li>
</ol>
<div class="callout note">
<strong>What gets preserved across updates</strong>
Everything in <code>./data/</code> is volume-mounted. Lists, items, per-location check states, and
<code>lastCheckDate</code> values are persisted. Configuration in <code>.env</code> is also untouched by an update unless
the new tarball explicitly ships a replacement.
</div>
<h3>4.4 Rollback</h3>
<p>If an update misbehaves:</p>
<ol>
<li>Recover the previous <code>checklist-deploy.tgz</code> (kept by the developer) or restore from Hyper Backup.</li>
<li>Replace the files in <code>/volume1/docker/recurring-checklist/</code> as in §4.2.</li>
<li>Rebuild the project as in §4.3.</li>
</ol>
<h2 id="backup">5. Backups</h2>
<h3>5.1 What's backed up</h3>
<p>A Hyper Backup task named <strong>Recurring Checklist</strong> backs up the entire <code>docker</code>
shared folder nightly at 23:30 to Google Drive. Destination:</p>
<pre><code>Google Drive: recurring-checklist_server-backup / Chronicles-NA_1</code></pre>
<p>This captures both the application source (in <code>recurring-checklist/</code>) and its data folder.</p>
<h3>5.2 Restoring</h3>
<p>To restore the data file only:</p>
<ol>
<li>Open Hyper Backup → Recurring Checklist task → <strong>Restore</strong> → <strong>Data</strong>.</li>
<li>Pick a version. Browse into
<code>recurring-checklist/data/</code> and restore <code>checklists.json</code>.</li>
<li>Container Manager → Project → Stop, then Start the <code>recurring-checklist</code> project to pick up the restored file.</li>
</ol>
<h3>5.3 Backup verification</h3>
<p>An integrity check runs weekly on Saturday at 01:30. Failed integrity checks generate a DSM notification.</p>
<h2 id="dns">6. DDNS, reverse proxy &amp; HTTPS</h2>
<h3>6.1 DDNS record</h3>
<ul>
<li><strong>Hostname:</strong> <code>check.watersmedia.org</code></li>
<li><strong>Provider:</strong> Synology (free)</li>
<li><strong>Account:</strong> Registered to the Synology account on Chronicles-NA.</li>
</ul>
<h3>6.2 Reverse proxy rule (on Boba-Fett, 10.1.1.4)</h3>
<p>Located at <em>Control Panel → Login Portal → Advanced → Reverse Proxy → "Recurring Checklist"</em>.</p>
<table>
<tr><th>Side</th><th>Protocol</th><th>Hostname</th><th>Port</th></tr>
<tr><td>Source</td><td>HTTPS</td><td><code>check.watersmedia.org</code></td><td>443</td></tr>
<tr><td>Destination</td><td>HTTP</td><td><code>10.1.1.1</code></td><td>3000</td></tr>
</table>
<h3>6.3 TLS certificate</h3>
<ul>
<li><strong>Type:</strong> Let's Encrypt</li>
<li><strong>Bound to:</strong> the <code>check.watersmedia.org</code> service in Control Panel → Security → Certificate → Settings</li>
<li><strong>Auto-renewal:</strong> handled by DSM. The cert renews ~30 days before expiry as long as
ports 80/443 inbound continue to reach Boba-Fett.</li>
</ul>
<h3>6.4 Router (UniFi)</h3>
<p>Existing port-forward of TCP 443 to Boba-Fett (10.1.1.4) is the only inbound rule required. No
new forwarding is needed for the Recurring Checklist app.</p>
<h2 id="troubleshoot">7. Troubleshooting</h2>
<table>
<tr><th>Symptom</th><th>Likely cause &amp; fix</th></tr>
<tr>
<td>App returns 502 / Bad Gateway from <code>check.watersmedia.org</code></td>
<td>The Docker container on Chronicles-NA is stopped. Open Container Manager → Project → click
<code>recurring-checklist</code> → <strong>Action → Start</strong>. If it fails to start, open
the build log for errors (commonly a malformed <code>.env</code>).</td>
</tr>
<tr>
<td>Browser shows "Not Secure" on the public URL</td>
<td>Certificate either expired or not bound. Check Control Panel → Security → Certificate on Boba-Fett.
The <code>check.watersmedia.org</code> cert should be green and bound under
<strong>Settings</strong>.</td>
</tr>
<tr>
<td>Sunday email did not arrive</td>
<td>Open Container Manager → Container → <code>recurring-checklist</code> → <strong>Details → Log</strong>.
Look for lines beginning with <code>[digest]</code>. Common causes: expired Google App Password,
Google Workspace 2FA disabled on the sending account, outbound TCP 587 blocked by firewall.</td>
</tr>
<tr>
<td>"This list is reserved for X" error</td>
<td>Expected. The list is location-scoped; a user signed in as a different location cannot tick it
off. Either change the user's location to match the list or remove the list's location to make it
shared.</td>
</tr>
<tr>
<td>Checks reappear after a user signs out and back in</td>
<td>Per-location checks are stored on the server keyed by the user's typed name (case-insensitive,
whitespace-collapsed). If a user enters a different name, they are treated as a different
location and see different state.</td>
</tr>
<tr>
<td>Container won't build</td>
<td>Usually a stale or partial extraction. Re-upload <code>checklist-deploy.tgz</code> and extract
both <code>.tgz</code> and the intermediate <code>.tar</code> step. Confirm <code>server.js</code> and the <code>public/</code> folder
are present at the project root.</td>
</tr>
</table>
<h2 id="references">8. Reference</h2>
<h3>8.1 Files in <code>/volume1/docker/recurring-checklist/</code></h3>
<table>
<tr><th>Path</th><th>Purpose</th></tr>
<tr><td><code>server.js</code></td><td>Express app + scheduler + email logic.</td></tr>
<tr><td><code>public/</code></td><td>Static front-end (HTML, CSS, JS).</td></tr>
<tr><td><code>Dockerfile</code></td><td>Image definition (node:20-alpine).</td></tr>
<tr><td><code>docker-compose.yml</code></td><td>Container Manager project definition.</td></tr>
<tr><td><code>package.json</code> / <code>package-lock.json</code></td><td>npm manifest, locked dependency versions.</td></tr>
<tr><td><code>.env</code></td><td>Runtime config (passwords, SMTP, schedule). Not in version control.</td></tr>
<tr><td><code>.env.example</code></td><td>Template for a fresh deploy.</td></tr>
<tr><td><code>data/checklists.json</code></td><td>Persistent state. Volume-mounted; backed up nightly.</td></tr>
</table>
<h3>8.2 Useful container actions</h3>
<table>
<tr><th>Action</th><th>Where</th></tr>
<tr><td>View live logs</td><td>Container Manager → Container → <code>recurring-checklist</code> → Details → Log</td></tr>
<tr><td>Restart container</td><td>Container Manager → Project → recurring-checklist → Action → Stop, then Start</td></tr>
<tr><td>Rebuild image after update</td><td>Container Manager → Project → recurring-checklist → Action → Build</td></tr>
<tr><td>Edit env vars</td><td>Edit <code>/volume1/docker/recurring-checklist/.env</code> via File Station, then restart the project</td></tr>
</table>
<h3>8.3 Server endpoints (for diagnostics)</h3>
<table>
<tr><th>Method</th><th>Path</th><th>Purpose</th></tr>
<tr><td>GET</td><td><code>/api/state</code></td><td>Returns current lists, items, and per-location check map (read-only).</td></tr>
<tr><td>POST</td><td><code>/api/admin/verify</code></td><td>Validates the admin password (used by the UI).</td></tr>
<tr><td>POST</td><td><code>/api/admin/state</code></td><td>Replaces full state. Requires <code>X-Admin-Password</code>.</td></tr>
<tr><td>POST</td><td><code>/api/admin/send-digest</code></td><td>Manually triggers the email digest. Requires admin.</td></tr>
<tr><td>POST</td><td><code>/api/check</code></td><td>Toggle a single item's checked state for a location.</td></tr>
</table>
<hr>
<p class="small">
Document version 1 · Generated for the Recurring Checklist deployment on Chronicles-NA / Boba-Fett.
Maintained alongside the project source.
</p>
</body>
</html>