42 Commits
Author SHA1 Message Date
blaisadmin 3ba6410972 Merge branch 'dev'
Deploy / deploy-dev (push) Skipped
Deploy / deploy-prod (push) Successful in 3m3s
# Conflicts:
#	backend/src/app.ts
#	backend/src/db/schema.ts
#	backend/src/queues/adoptionReportQueue.ts
#	backend/src/reports/adoptionReport.ts
#	backend/src/reports/adoptionReportJob.ts
#	backend/src/repositories/birdRepository.ts
#	backend/src/types.ts
#	frontend/src/App.tsx
#	frontend/src/index.css
2026-07-21 22:25:29 -04:00
blaisadmin 4a43a450f3 Additional Genders
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m43s
2026-06-17 22:04:42 -04:00
Corey Blais 46605d8717 Limit editable weight entries to three
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m30s
2026-06-17 10:08:24 -04:00
Corey Blais 8f1144de1a Allow four editable weight entries
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 3m2s
2026-06-17 09:57:23 -04:00
Corey Blais 53b75588a2 weight edit fixes
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m36s
2026-06-16 18:13:54 -04:00
Corey Blais 1849ecd73b Updated weight edit 2026-06-16 18:13:54 -04:00
Corey Blais 53b7d34520 trimmed weight edit 2026-06-16 18:13:54 -04:00
Corey Blais f65a4bed24 adding weight edits 2026-06-16 18:13:54 -04:00
blaisadmin cc4a2382c6 Add production health checks
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m46s
2026-06-05 22:28:48 -04:00
blaisadmin 5735bb7735 Adding promoting to owner
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m23s
2026-06-05 21:45:58 -04:00
blaisadmin 88ff06237e Adjusting role actions 2026-06-05 21:45:58 -04:00
Corey Blais fbb13561b0 Medication reminder and pdr worker 2026-06-05 21:45:58 -04:00
Corey Blais b15861c856 Use fixed screen size for adoption report sheet 2026-06-05 21:42:20 -04:00
Corey Blais 2aeaa119f7 Wrap adoption report in letter sheet 2026-06-05 21:42:20 -04:00
Corey Blais 36690c0174 Tighten adoption report page scale 2026-06-05 21:42:20 -04:00
Corey Blais b76ad35c07 Size adoption report for letter paper 2026-06-05 21:42:20 -04:00
Corey Blais 6918b55a58 Stabilize adoption report layout 2026-06-05 21:42:20 -04:00
blaisadmin 49f1713e26 Add flock member notes and audit tabs 2026-06-05 21:42:20 -04:00
Corey Blais c9fa7e4246 Cover consumed adoption transfer codes
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m45s
2026-06-03 14:12:10 -04:00
Corey Blais 0411ec5175 Keep adoption transfer codes stable
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m17s
2026-06-03 12:10:27 -04:00
Corey Blais 7b7171c109 Prevent report card text overlap
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m47s
2026-06-03 11:54:44 -04:00
Corey Blais c02bb4d6d8 Convert report photos for PDF embedding
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m37s
2026-06-03 11:20:52 -04:00
Corey Blais 603b4eee4d Render adoption reports in worker
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 1m43s
2026-06-03 10:55:09 -04:00
Corey Blais 52008f5b43 Fix adoption report photos and section order
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m23s
2026-06-03 10:05:48 -04:00
Corey Blais 5b57cdd6bf Fix adoption report QR and chart layout 2026-06-03 10:05:48 -04:00
Corey Blais 60eadf0847 Refine adoption report header layout 2026-06-03 10:05:48 -04:00
Corey Blais 682ccfd41f Generate adoption reports as PDFs 2026-06-03 10:05:48 -04:00
Corey Blais 59c6b19ad6 fixing analytics icon pt2
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m15s
2026-06-02 12:55:35 -04:00
Corey Blais aa1a4cf6ff fixing analytics icon 2026-06-02 12:55:35 -04:00
Corey Blais 5f0fad3cbb Updated adoption report 2026-06-02 12:55:35 -04:00
Corey Blais 545fae59b2 Added adoption report and transfer code 2026-06-02 12:55:35 -04:00
Corey Blais d748d2db21 Added vet info
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m14s
2026-06-01 15:20:47 -04:00
blaisadmin 095c91e56d Merge branch 'release/flock-member-notes-audit'
Deploy / deploy-dev (push) Has been skipped
Deploy / deploy-prod (push) Successful in 2m16s
2026-05-30 22:50:54 -04:00
blaisadmin f2017068d5 Auto deploy production on main merge 2026-05-30 22:47:11 -04:00
blaisadmin c9702495a3 Add flock member notes and audit tabs 2026-05-30 22:46:31 -04:00
blaisadmin e965cb55ef Revert education feature from main 2026-05-30 15:50:23 -04:00
blaisadmin 505a9b8496 Added backend limits 2026-05-30 15:43:38 -04:00
blaisadmin c6dc5b22b8 Merge dev into main 2026-05-30 15:29:48 -04:00
blaisadmin f16e88e2f0 Validate builds before deploy 2026-05-24 21:48:42 -04:00
blaisadmin 016bc187d4 Run deploy compose from host repo paths 2026-05-24 11:33:58 -04:00
blaisadmin 104f01f75d Use local mounts for production deploy workflow 2026-05-23 18:38:49 -04:00
blaisadmin 568aee3e70 Adding gitea action 2026-05-23 16:32:59 -04:00
32 changed files with 341 additions and 2554 deletions
-7
View File
@@ -39,10 +39,3 @@ STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY=
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
# Daily email for birds without a new weight entry in 48 hours; uses the milestone time zone.
WEIGHT_REMINDERS_ENABLED=true
# Optional absolute HTTPS image URL; defaults to FRONTEND_URL/email-background.png.
# Deploy frontend/public/email-background.png before sending the updated email templates.
EMAIL_BACKGROUND_URL=
+3 -124
View File
@@ -3,13 +3,14 @@ name: Deploy
on:
push:
branches:
- main
- dev
- develop
workflow_dispatch:
jobs:
deploy-dev:
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'develop') }}
runs-on: ubuntu-latest
container:
volumes:
@@ -47,69 +48,8 @@ jobs:
cd /docker/FlockPal-dev
docker compose -f docker-compose.dev.yaml up -d --build
- name: Notify Discord
if: always()
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
JOB_STATUS: ${{ job.status }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "DISCORD_WEBHOOK_URL is not configured; skipping Discord notification."
exit 0
fi
if [ "$JOB_STATUS" = "success" ]; then
STATUS_TITLE="Deploy succeeded"
COLOR=65280
else
STATUS_TITLE="Deploy failed"
COLOR=16711680
fi
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
AVATAR_URL="${{ vars.DISCORD_AVATAR_URL }}"
AVATAR_URL="${AVATAR_URL:-https://www.flockpal.app/FlockPal-Paint.png}"
COMMIT_MESSAGE="$(git -C /docker/FlockPal-dev log -1 --pretty=%s 2>/dev/null || printf '%s' '${{ github.sha }}')"
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
BRANCH="$(json_escape '${{ github.ref_name }}')"
MESSAGE="$(json_escape "$COMMIT_MESSAGE")"
AVATAR_URL="$(json_escape "$AVATAR_URL")"
cat > /tmp/discord-payload.json <<EOF
{
"username": "FlockPal Build",
"avatar_url": "${AVATAR_URL}",
"embeds": [
{
"title": "${STATUS_TITLE}",
"url": "${RUN_URL}",
"description": "FlockPal dev deploy",
"color": ${COLOR},
"fields": [
{
"name": "Branch",
"value": "${BRANCH}",
"inline": true
},
{
"name": "Message",
"value": "${MESSAGE}",
"inline": false
}
]
}
]
}
EOF
curl --fail --show-error --silent \
-H "Content-Type: application/json" \
-d @/tmp/discord-payload.json \
"$DISCORD_WEBHOOK_URL" || echo "Discord notification failed."
deploy-prod:
if: ${{ github.event_name == 'workflow_dispatch' }}
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == 'main') }}
runs-on: ubuntu-latest
container:
volumes:
@@ -146,64 +86,3 @@ jobs:
set -e
cd /docker/FlockPal
docker compose -f docker-compose.prod.yml up -d --build
- name: Notify Discord
if: always()
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
JOB_STATUS: ${{ job.status }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "DISCORD_WEBHOOK_URL is not configured; skipping Discord notification."
exit 0
fi
if [ "$JOB_STATUS" = "success" ]; then
STATUS_TITLE="Deploy succeeded"
COLOR=65280
else
STATUS_TITLE="Deploy failed"
COLOR=16711680
fi
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
AVATAR_URL="${{ vars.DISCORD_AVATAR_URL }}"
AVATAR_URL="${AVATAR_URL:-https://www.flockpal.app/FlockPal-Paint.png}"
COMMIT_MESSAGE="$(git -C /docker/FlockPal log -1 --pretty=%s 2>/dev/null || printf '%s' '${{ github.sha }}')"
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
BRANCH="$(json_escape '${{ github.ref_name }}')"
MESSAGE="$(json_escape "$COMMIT_MESSAGE")"
AVATAR_URL="$(json_escape "$AVATAR_URL")"
cat > /tmp/discord-payload.json <<EOF
{
"username": "FlockPal Build",
"avatar_url": "${AVATAR_URL}",
"embeds": [
{
"title": "${STATUS_TITLE}",
"url": "${RUN_URL}",
"description": "FlockPal production deploy",
"color": ${COLOR},
"fields": [
{
"name": "Branch",
"value": "${BRANCH}",
"inline": true
},
{
"name": "Message",
"value": "${MESSAGE}",
"inline": false
}
]
}
]
}
EOF
curl --fail --show-error --silent \
-H "Content-Type: application/json" \
-d @/tmp/discord-payload.json \
"$DISCORD_WEBHOOK_URL" || echo "Discord notification failed."
+17 -30
View File
@@ -47,6 +47,23 @@ The default `docker-compose.yml` is development-only. It mounts source files, in
## Operations
### Health checks
Monitor these production checks:
- Frontend: `GET https://your-host/healthz`
- Verifies Nginx is serving the frontend container.
- Backend liveness: `GET https://your-host/api/health/live`
- Verifies the API process is running.
- Backend readiness: `GET https://your-host/api/health/ready`
- Verifies the API can reach Postgres and Redis. Returns `503` if either dependency is unavailable.
- Backend metrics: `GET https://your-host/api/metrics`
- Admin-authenticated process, request, and queue metrics.
- Postgres and Redis:
- Use the Docker health checks in `docker-compose.prod.yml`.
- Worker:
- Use the Docker health check in `docker-compose.prod.yml`; it validates worker dependencies. The worker does not expose HTTP.
### Backups
Create a compressed Postgres backup from the Docker Compose Postgres service:
@@ -95,8 +112,6 @@ curl -H "Authorization: Bearer <admin-token>" https://your-host/api/metrics
- `S3_ACCESS_KEY_ID`
- `S3_SECRET_ACCESS_KEY`
- `RESCUE_ONBOARDING_WEBHOOK_URL`
- `DISCORD_WEBHOOK_URL` as a Gitea Actions secret for deploy notifications
- `DISCORD_AVATAR_URL` as an optional Gitea Actions variable for the deploy notification icon
2. Build and start the production stack:
```bash
@@ -161,34 +176,6 @@ npm run build
npm run worker
```
### Overdue weight emails
The worker checks once per calendar day for living birds with no new weight entry in
48 hours. Each accepted owner, assistant, or caregiver receives one email per flock
listing all birds due for a reminder. The branded email embeds the FlockPal logo
and each bird's portrait (including private S3 photos), with the default portrait
used when a photo is missing or unavailable. Pending invitees and viewers are excluded.
Reminders repeat daily while overdue; adding a new weight restarts the
48-hour clock. Birds without weights use their profile creation time.
Scheduling matches hatch-day reminders: a check 15 seconds after worker startup,
then hourly, with one queued job per local date. There is no fixed morning send
time. Both use `MILESTONE_REMINDER_TIME_ZONE` (default `America/New_York`).
Delivery tracking uses local calendar dates, including daylight-saving changes.
Deploying this change removes the old five-minute Redis schedule.
The clock uses the weight entry's creation timestamp, since the measurement date
has no time of day. Editing an existing entry does not restart the clock; entering
a historical weight does. Memorialized birds are excluded.
`WEIGHT_REMINDERS_ENABLED` defaults to `true` in both Compose stacks. Set it to
`false` to disable delivery. The worker, Redis, Postgres, and existing SMTP settings
must be available. The schema initializer creates the delivery tracking table.
Missing SMTP configuration or failed sends leave reminders eligible for retry.
Delivery tracking prevents ordinary repeat sends and concurrent claims; as with
other SMTP delivery, a crash after acceptance but before saving delivery can cause
a duplicate on retry.
## Auth and flock notes
- One user can belong to multiple flocks.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

@@ -1,13 +0,0 @@
// Rebuild the email-safe PNG from the app's SVG tracks and background palette.
// Run from backend: node scripts/generate-email-background.mjs
import { readFile, writeFile } from 'node:fs/promises';
import sharp from 'sharp';
const css = await readFile(new URL('../../frontend/src/index.css', import.meta.url), 'utf8');
const encoded = css.match(/background-image: url\("data:image\/svg\+xml,([^"\n]+)"\)/)?.[1];
if (!encoded) throw new Error('App bird-track background was not found');
const tracks = decodeURIComponent(encoded);
const gradients = `<defs><linearGradient id="wash" x2="0" y2="1"><stop stop-color="#fef5e7"/><stop offset=".46" stop-color="#e9ddba"/><stop offset="1" stop-color="#d9eadf"/></linearGradient>${[[14,10,22,'#de7c3a',.28],[82,12,20,'#35886e',.26],[24,84,22,'#ddb34e',.2],[86,78,24,'#2b765c',.24],[62,54,16,'#3072a0',.14]].map(([x,y,r,color,opacity],i)=>`<radialGradient id="glow${i}" cx="${x}%" cy="${y}%" r="${r}%"><stop stop-color="${color}" stop-opacity="${opacity}"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></radialGradient>`).join('')}</defs>`;
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="800" height="1100" viewBox="0 0 1600 2200">${gradients}<rect width="1600" height="2200" fill="url(#wash)"/>${[0,1,2,3,4].map(i=>`<rect width="1600" height="2200" fill="url(#glow${i})"/>`).join('')}<g opacity=".42">${tracks.replace(/^<svg[^>]*>/,'').replace(/<\/svg>$/,'')}</g></svg>`;
await writeFile(new URL('../assets/email-background.png', import.meta.url), await sharp(Buffer.from(svg)).png().toBuffer());
await writeFile(new URL('../../frontend/public/email-background.png', import.meta.url), await readFile(new URL('../assets/email-background.png', import.meta.url)));
+182 -259
View File
@@ -1,6 +1,3 @@
import { buildEmailLayout } from './emails/emailLayout.js';
import { buildWeightReminderEmail } from './emails/weightReminderEmail.js';
import type { WeightReminder } from './reminders/weightReminders.js';
import crypto from 'crypto';
import { existsSync } from 'fs';
import path from 'path';
@@ -11,7 +8,7 @@ import express, { type NextFunction, type Request, type Response } from 'express
import rateLimit from 'express-rate-limit';
import helmet from 'helmet';
import morgan from 'morgan';
import nodemailer from 'nodemailer';
import nodemailer, { type SendMailOptions } from 'nodemailer';
import Stripe from 'stripe';
import { z } from 'zod';
@@ -81,19 +78,6 @@ import {
listAuditLogEntries,
listFlockNotes,
} from './repositories/auditRepository.js';
import {
deleteDailyEducation,
deleteEducationQuestion,
createEducationQuestion,
getDailyEducationForDate,
getEducationOptOut,
listDailyEducationForAdmin,
listDailyEducationQuestions,
listEducationQuestionsForAdmin,
updateEducationOptOut,
updateEducationQuestion,
upsertDailyEducation,
} from './repositories/educationRepository.js';
import {
buildBirdPhotoObjectKey,
getImageExtensionFromContentType,
@@ -133,8 +117,6 @@ import type {
AuditLogEntryRow,
BillingInterval,
BillingPlan,
DailyEducationRow,
EducationQuestionRow,
BirdGender,
BirdMilestoneReminderCandidateRow,
BirdRow,
@@ -540,27 +522,6 @@ const integrationTokenCreateSchema = z.object({
expiresInDays: z.coerce.number().int().min(1).max(3650).optional(),
});
const educationQuestionSchema = z
.object({
prompt: z.string().trim().min(1).max(500),
options: z.array(z.string().trim().min(1).max(240)).min(2).max(4),
correctAnswerIndex: z.coerce.number().int().min(0).max(3),
explanation: z.string().trim().max(800).optional().or(z.literal('')),
})
.refine((value) => value.correctAnswerIndex < value.options.length, {
message: 'Correct answer must match one of the quiz options.',
path: ['correctAnswerIndex'],
});
const dailyEducationSchema = z.object({
publishDate: dateStringSchema,
fact: z.string().trim().min(1).max(2000),
});
const educationPreferenceSchema = z.object({
educationOptOut: z.boolean(),
});
const emptyToNull = (value?: string) => {
const trimmed = value?.trim() ?? '';
return trimmed ? trimmed : null;
@@ -755,25 +716,6 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
createdAt: row.created_at,
});
const normalizeEducationQuestion = (row: EducationQuestionRow) => ({
id: row.id,
prompt: row.prompt,
options: row.options,
correctAnswerIndex: Number(row.correct_answer_index),
explanation: row.explanation ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
const normalizeDailyEducation = (row: DailyEducationRow, questions: EducationQuestionRow[] = []) => ({
id: row.id,
publishDate: row.publish_date,
fact: row.fact,
quizQuestions: questions.map(normalizeEducationQuestion),
createdAt: row.created_at,
updatedAt: row.updated_at,
});
const signBirdPhotoAccessToken = (row: BirdRow) => {
if (!row.photo_object_key) {
return '';
@@ -1397,14 +1339,12 @@ const sendMagicLink = async ({
'',
'This link expires in 15 minutes and can only be used once.',
].join('\n'),
...await buildEmailLayout({ eyebrow: 'Sign in', headline: 'Your FlockPal sign-in link',
contentHtml: `
<p>Hi ${escapeHtml(name || 'there')},</p>
html: `
<p>Hi ${name || 'there'},</p>
<p>Use this secure link to sign in to FlockPal:</p>
<p><a href="${magicLinkUrl}">Sign in to FlockPal</a></p>
<p>This link expires in 15 minutes and can only be used once.</p>
`,
action: { url: magicLinkUrl, label: 'Sign in to FlockPal' },
}),
`,
});
return {
@@ -1475,6 +1415,27 @@ const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => {
return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0;
};
const getFlockPalLogoAttachment = () => {
const logoPath = path.join(process.cwd(), 'assets', 'flockpal-logo.png');
if (!existsSync(logoPath)) {
console.warn(`Unable to load FlockPal email logo from ${logoPath}`);
return null;
}
return {
filename: 'flockpal-logo.png',
path: logoPath,
cid: 'flockpal-logo',
contentDisposition: 'inline' as const,
};
};
const getEmailTrackPatternDataUrl = () => {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="680" height="188" viewBox="0 0 680 188"><defs><linearGradient id="wash" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fef5e7"/><stop offset=".52" stop-color="#e9ddba"/><stop offset="1" stop-color="#d9eadf"/></linearGradient><symbol id="track" viewBox="0 0 160 160"><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(30 80 80)"/><rect x="66" y="12" width="28" height="136" rx="14" transform="rotate(-30 80 80)"/></symbol></defs><rect width="680" height="188" fill="url(#wash)"/><g opacity=".68"><use href="#track" x="20" y="16" width="88" height="88" fill="#5bb3b7" transform="rotate(-12 64 60)"/><use href="#track" x="126" y="74" width="78" height="78" fill="#7eb773" transform="rotate(18 165 113)"/><use href="#track" x="232" y="20" width="104" height="104" fill="#f3a24a" transform="rotate(-26 284 72)"/><use href="#track" x="378" y="72" width="86" height="86" fill="#898b93" transform="rotate(28 421 115)"/><use href="#track" x="492" y="18" width="98" height="98" fill="#b9c945" transform="rotate(-18 541 67)"/><use href="#track" x="592" y="84" width="66" height="66" fill="#5bb3b7" transform="rotate(34 625 117)"/></g><g opacity=".32"><use href="#track" x="66" y="112" width="46" height="46" fill="#f3a24a" transform="rotate(36 89 135)"/><use href="#track" x="190" y="122" width="42" height="42" fill="#5bb3b7" transform="rotate(-20 211 143)"/><use href="#track" x="344" y="18" width="44" height="44" fill="#7eb773" transform="rotate(18 366 40)"/><use href="#track" x="474" y="126" width="48" height="48" fill="#f3a24a" transform="rotate(-34 498 150)"/><use href="#track" x="626" y="18" width="42" height="42" fill="#898b93" transform="rotate(22 647 39)"/></g></svg>`;
return `data:image/svg+xml,${encodeURIComponent(svg)}`;
};
const parseDataImage = (dataUrl: string) => {
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
if (!match) {
@@ -1619,6 +1580,22 @@ const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
return Buffer.from(await imageResponse.arrayBuffer());
};
const getDefaultBirdPhotoAttachment = () => {
const defaultPhotoPath = path.join(process.cwd(), 'assets', 'yoda-default.png');
if (!existsSync(defaultPhotoPath)) {
console.warn(`Unable to load default bird photo from ${defaultPhotoPath}`);
return null;
}
return {
filename: 'yoda-default.png',
path: defaultPhotoPath,
cid: 'flockpal-default-bird-photo',
contentDisposition: 'inline' as const,
};
};
const sendRescueStatusNotification = async ({
workspace,
ownerEmail,
@@ -1668,8 +1645,7 @@ const sendRescueStatusNotification = async ({
to: rescueStatusNotificationEmail,
subject,
text: lines.join('\n'),
...await buildEmailLayout({ eyebrow: 'Rescue status', headline: 'Rescue flock update',
contentHtml: `
html: `
<p>A rescue flock was ${eventLabel}.</p>
<ul>
<li><strong>Rescue flock:</strong> ${escapedWorkspaceName}</li>
@@ -1679,9 +1655,7 @@ const sendRescueStatusNotification = async ({
<li><strong>Flock ID:</strong> ${workspace.id}</li>
</ul>
${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''}
`,
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
}),
`,
});
return { delivered: true };
@@ -1820,15 +1794,13 @@ const issueBirdTransferInvite = async ({
to: email,
subject,
text,
...await buildEmailLayout({ eyebrow: 'Bird transfer', headline: 'A bird is joining your flock',
contentHtml: `
html: `
<p>Hi there,</p>
<p><strong>${escapeHtml(sourceWorkspaceName)}</strong> wants to transfer <strong>${escapeHtml(birdName)}</strong> to your FlockPal account.</p>
<p>Use this secure invite link to sign in or create your account. FlockPal will automatically create your receiving flock and complete any pending bird transfers for this email.</p>
<p><a href="${magicLinkUrl}">Accept bird transfer in FlockPal</a></p>
<p>This link expires in 15 minutes and can only be used once.</p>
`,
action: { url: magicLinkUrl, label: 'Accept bird transfer' },
}),
`,
});
return {
@@ -1884,8 +1856,7 @@ const sendLostBirdReportNotification = async ({
replyTo: emptyToNull(report.finderEmail) ?? undefined,
subject,
text: lines.join('\n'),
...await buildEmailLayout({ eyebrow: 'Found bird report', headline: 'A possible match for your bird',
contentHtml: `
html: `
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
<ul>
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id ?? 'Not recorded')}</li>
@@ -1897,9 +1868,7 @@ const sendLostBirdReportNotification = async ({
<li><strong>Message:</strong> ${escapeHtml(message)}</li>
</ul>
<p>FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.</p>
`,
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
}),
`,
});
return { delivered: true };
@@ -1975,7 +1944,7 @@ const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) =
return {
subject: `${reminder.medication_name} reminder for ${reminder.name}`,
eyebrow: 'Medication Reminder',
headline: `Medication time for ${reminder.name}`,
headline: `${slotLabel} time for ${reminder.name}`,
intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`,
body: `Dose: ${reminder.dosage}${route}.`,
detailLabel: `${slotLabel} at ${doseTime}`,
@@ -1996,6 +1965,32 @@ const sendBirdMilestoneReminderNotification = async ({
}
const copy = buildBirdMilestoneReminderCopy(reminder);
const attachments: NonNullable<SendMailOptions['attachments']> = [];
const logoAttachment = getFlockPalLogoAttachment();
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
if (logoAttachment) {
attachments.push(logoAttachment);
}
if (uploadedBirdPhoto) {
attachments.push({
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
content: uploadedBirdPhoto.content,
contentType: uploadedBirdPhoto.contentType,
cid: birdPhotoCid,
contentDisposition: 'inline',
});
} else if (defaultBirdPhoto) {
attachments.push(defaultBirdPhoto);
}
const birdPhotoHtml = birdPhotoCid
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
const lines = [
copy.headline,
'',
@@ -2020,18 +2015,43 @@ const sendBirdMilestoneReminderNotification = async ({
bcc: uniqueRecipients,
subject: copy.subject,
text: lines.join('\n'),
...await buildEmailLayout({
bird: reminder,
eyebrow: copy.eyebrow,
headline: copy.headline,
preheader: copy.intro,
contentHtml: `<p style="margin:0 0 16px;font-size:16px;">${escapeHtml(copy.intro)}</p>
<p>${escapeHtml(copy.body)}</p>
<p><strong>${escapeHtml(reminder.name)}</strong> · ${escapeHtml(reminder.species)}</p>
<p>${escapeHtml(`${copy.eventName}: ${copy.milestoneLabel}`)}</p>
`,
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
}),
attachments,
html: `
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
<div style="max-width: 680px; margin: 0 auto 18px;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
${
logoAttachment
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
}
</div>
<div style="padding: 30px 28px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
<tr>
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
${birdPhotoHtml}
</td>
<td style="vertical-align: top; padding: 0 0 20px;">
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
</td>
</tr>
</table>
<p style="margin: 4px 0 18px; font-size: 16px;">${escapeHtml(copy.body)}</p>
<p style="margin: 0;">
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
</p>
</div>
</div>
<div style="max-width: 680px; margin: 18px auto 0;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
</div>
`,
});
return { delivered: true };
@@ -2051,6 +2071,35 @@ const sendMedicationReminderNotification = async ({
}
const copy = buildMedicationReminderCopy(reminder);
const attachments: NonNullable<SendMailOptions['attachments']> = [];
const logoAttachment = getFlockPalLogoAttachment();
const trackPatternDataUrl = getEmailTrackPatternDataUrl();
const uploadedBirdPhoto = reminder.photo_data_url ? parseDataImage(reminder.photo_data_url) : null;
const defaultBirdPhoto = uploadedBirdPhoto ? null : getDefaultBirdPhotoAttachment();
const birdPhotoCid = uploadedBirdPhoto ? 'bird-photo' : defaultBirdPhoto ? defaultBirdPhoto.cid : '';
if (logoAttachment) {
attachments.push(logoAttachment);
}
if (uploadedBirdPhoto) {
attachments.push({
filename: `${reminder.name.replace(/[^a-z0-9_-]+/gi, '-').toLowerCase() || 'bird'}-photo`,
content: uploadedBirdPhoto.content,
contentType: uploadedBirdPhoto.contentType,
cid: birdPhotoCid,
contentDisposition: 'inline',
});
} else if (defaultBirdPhoto) {
attachments.push(defaultBirdPhoto);
}
const birdPhotoHtml = birdPhotoCid
? `<img src="cid:${birdPhotoCid}" alt="${escapeHtml(reminder.name)}" style="display: block; width: 148px; height: 148px; border-radius: 28px; object-fit: cover; border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18);" />`
: `<div style="display: grid; place-items: center; width: 148px; height: 148px; border-radius: 28px; background: linear-gradient(135deg, #fff8ef, #eaf7ef); border: 4px solid #fff8ef; box-shadow: 0 14px 30px rgba(38, 51, 49, 0.18); color: #238a5a; font-size: 64px; font-weight: 800;">${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}</div>`;
const medicationNotesHtml = reminder.medication_notes
? `<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>`
: '';
const lines = [
copy.headline,
'',
@@ -2077,18 +2126,46 @@ const sendMedicationReminderNotification = async ({
bcc: uniqueRecipients,
subject: copy.subject,
text: lines.join('\n'),
...await buildEmailLayout({
bird: reminder,
eyebrow: copy.eyebrow,
headline: copy.headline,
preheader: copy.intro,
contentHtml: `<p style="margin:0 0 16px;font-size:16px;">${escapeHtml(copy.intro)}</p>
<p>${escapeHtml(copy.body)}</p>
<p><strong>${escapeHtml(reminder.name)}</strong> · ${escapeHtml(reminder.species)}</p>
<p>${escapeHtml(copy.detailLabel)}</p>
${reminder.medication_notes ? `<p><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>` : ''}`,
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
}),
attachments,
html: `
<div style="margin: 0; padding: 28px; background-color: #fef5e7; background-image: url('${trackPatternDataUrl}'), radial-gradient(circle at 14% 10%, rgba(222, 124, 58, 0.24), transparent 22%), radial-gradient(circle at 82% 12%, rgba(53, 136, 110, 0.22), transparent 20%), linear-gradient(180deg, #fef5e7 0%, #e9ddba 46%, #d9eadf 100%); background-repeat: repeat, no-repeat, no-repeat, no-repeat; font-family: Arial, sans-serif; color: #1f2a2a; line-height: 1.6;">
<div style="max-width: 680px; margin: 0 auto 18px;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
<div style="max-width: 680px; margin: 0 auto; overflow: hidden; border-radius: 30px; background-color: #e7f4e9; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.44), transparent 42%), linear-gradient(180deg, rgba(235, 247, 237, 0.98), rgba(211, 235, 220, 0.96)); border: 1px solid rgba(53, 129, 98, 0.34); box-shadow: 0 22px 44px rgba(89, 48, 42, 0.14);">
<div style="padding: 24px 28px; background-color: #edf8ef; background-image: linear-gradient(135deg, rgba(255, 255, 255, 0.46), transparent 46%), linear-gradient(180deg, rgba(242, 250, 243, 0.98), rgba(220, 241, 226, 0.94)); border-bottom: 1px solid rgba(53, 129, 98, 0.18);">
${
logoAttachment
? '<img src="cid:flockpal-logo" alt="FlockPal" style="display: block; width: 180px; max-width: 72%; height: auto;" />'
: '<strong style="display: block; color: #238a5a; font-size: 22px;">FlockPal</strong>'
}
</div>
<div style="padding: 30px 28px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse: collapse;">
<tr>
<td style="vertical-align: top; padding: 0 24px 20px 0; width: 160px;">
${birdPhotoHtml}
</td>
<td style="vertical-align: top; padding: 0 0 20px;">
<p style="margin: 0 0 8px; color: #238a5a; font-size: 13px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em;">${escapeHtml(copy.eyebrow)}</p>
<h1 style="margin: 0 0 12px; color: #1f2a2a; font-size: 30px; line-height: 1.12;">${escapeHtml(copy.headline)}</h1>
<p style="margin: 0; color: #63562d; font-size: 17px;">${escapeHtml(copy.intro)}</p>
</td>
</tr>
</table>
<p style="margin: 4px 0 10px; font-size: 16px;">${escapeHtml(copy.body)}</p>
<p style="margin: 0 0 18px; font-size: 15px; color: #63562d;"><strong>Schedule:</strong> ${escapeHtml(copy.detailLabel)}</p>
${medicationNotesHtml}
<p style="margin: 0;">
<a href="${frontendBaseUrl}" style="display: inline-block; padding: 12px 18px; border-radius: 999px; background: linear-gradient(135deg, #238a5a, #2f8f98); color: #ffffff; text-decoration: none; font-weight: 700; box-shadow: 0 12px 24px rgba(72, 97, 62, 0.16);">Open FlockPal</a>
</p>
</div>
</div>
<div style="max-width: 680px; margin: 18px auto 0;">
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
</div>
</div>
`,
});
return { delivered: true };
@@ -2138,17 +2215,6 @@ export const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) =
};
};
export const sendWeightReminderNotification = async (reminders: WeightReminder[]): Promise<boolean> => {
if (!mailTransport || !reminders.length) return false;
const first = reminders[0];
const result = await mailTransport.sendMail({
from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
to: first.recipient,
...await buildWeightReminderEmail(reminders, frontendBaseUrl),
});
return result.accepted.length > 0;
};
export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
const reminders = await listDueMedicationReminders(runDate, currentTime);
let sent = 0;
@@ -2893,149 +2959,6 @@ app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, require
}
});
app.get('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
try {
const education = await listDailyEducationForAdmin();
res.json({ education: education.map((entry) => normalizeDailyEducation(entry)) });
} catch (error) {
next(error);
}
});
app.get('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
try {
const questions = await listEducationQuestionsForAdmin();
res.json({ questions: questions.map(normalizeEducationQuestion) });
} catch (error) {
next(error);
}
});
app.put('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = dailyEducationSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid daily education payload', details: parsed.error.flatten() });
return;
}
try {
const education = await upsertDailyEducation({
publishDate: parsed.data.publishDate,
fact: parsed.data.fact,
createdByUserId: req.auth!.user.id,
});
res.json({ education: normalizeDailyEducation(education) });
} catch (error) {
next(error);
}
});
app.post('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = educationQuestionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() });
return;
}
try {
const question = await createEducationQuestion({
question: { ...parsed.data, explanation: emptyToNull(parsed.data.explanation) },
createdByUserId: req.auth!.user.id,
});
res.status(201).json({ question: normalizeEducationQuestion(question) });
} catch (error) {
next(error);
}
});
app.put('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = educationQuestionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() });
return;
}
try {
const question = await updateEducationQuestion(req.params.questionId, {
...parsed.data,
explanation: emptyToNull(parsed.data.explanation),
});
if (!question) {
res.status(404).json({ error: 'Education question not found.' });
return;
}
res.json({ question: normalizeEducationQuestion(question) });
} catch (error) {
next(error);
}
});
app.delete('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteEducationQuestion(req.params.questionId);
if (!deleted) {
res.status(404).json({ error: 'Education question not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.delete('/api/admin/daily-education/:educationId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteDailyEducation(req.params.educationId);
if (!deleted) {
res.status(404).json({ error: 'Daily education item not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.get('/api/education/today', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const educationOptOut = await getEducationOptOut(req.auth!.user.id);
const education = educationOptOut ? null : await getDailyEducationForDate();
const questions = education ? await listDailyEducationQuestions(education.publish_date) : [];
res.json({
educationOptOut,
education: education ? normalizeDailyEducation(education, questions) : null,
});
} catch (error) {
next(error);
}
});
app.patch('/api/education/preferences', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
const parsed = educationPreferenceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid education preference payload', details: parsed.error.flatten() });
return;
}
try {
const educationOptOut = await updateEducationOptOut(req.auth!.user.id, parsed.data.educationOptOut);
res.json({ educationOptOut });
} catch (error) {
next(error);
}
});
app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
-45
View File
@@ -30,9 +30,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE workspaces
DROP CONSTRAINT IF EXISTS workspaces_id_check;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
@@ -142,37 +139,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
ON auth_sessions (created_at DESC, user_id);
CREATE TABLE IF NOT EXISTS daily_education (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
publish_date DATE NOT NULL UNIQUE,
fact TEXT NOT NULL,
quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE daily_education
ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb;
CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date
ON daily_education (publish_date DESC);
CREATE TABLE IF NOT EXISTS education_question_bank (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt VARCHAR(500) NOT NULL,
options JSONB NOT NULL,
correct_answer_index INTEGER NOT NULL,
explanation VARCHAR(800),
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3)
);
CREATE INDEX IF NOT EXISTS idx_education_question_bank_created
ON education_question_bank (created_at DESC);
CREATE TABLE IF NOT EXISTS integration_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -480,17 +446,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
UNIQUE (bird_id, recorded_on)
);
CREATE TABLE IF NOT EXISTS weight_reminder_deliveries (
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
activity_at TIMESTAMPTZ NOT NULL,
recipient TEXT NOT NULL,
claim_token UUID NOT NULL,
claimed_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
delivered_at TIMESTAMPTZ,
PRIMARY KEY (bird_id, workspace_id, activity_at, recipient)
);
CREATE TABLE IF NOT EXISTS vet_visits (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
-29
View File
@@ -1,29 +0,0 @@
import { readFile } from 'node:fs/promises';
import sharp from 'sharp';
import type { WeightReminder } from '../reminders/weightReminders.js';
import { getS3ImageStorageConfig } from '../storage/imageStorageConfig.js';
import { getSignedS3ObjectUrl } from '../storage/s3Client.js';
const asset = (name: string) => new URL(`../../assets/${name}`, import.meta.url);
const thumbnail = (content: Buffer) => sharp(content).rotate().resize(192, 192, { fit: 'cover' }).jpeg({ quality: 80 }).toBuffer();
export const loadWeightReminderPortrait = async (bird: Pick<WeightReminder, 'bird_id' | 'photo_object_key' | 'photo_data_url'>): Promise<Buffer> => {
try {
if (bird.photo_object_key) {
const config = getS3ImageStorageConfig();
if (config) {
const response = await fetch(getSignedS3ObjectUrl({ config, objectKey: bird.photo_object_key, expiresInSeconds: 300 }), {
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) throw new Error(`Photo storage returned ${response.status}`);
return await thumbnail(Buffer.from(await response.arrayBuffer()));
}
}
const match = bird.photo_data_url?.match(/^data:image\/(?:png|jpe?g|webp|gif);base64,(.+)$/);
if (match) return await thumbnail(Buffer.from(match[1], 'base64'));
} catch {
console.warn(`Unable to load weight reminder portrait for bird ${bird.bird_id}; using default portrait.`);
}
return thumbnail(await readFile(asset('yoda-default.png')));
};
-36
View File
@@ -1,36 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { buildEmailLayout } from './emailLayout.js';
test('shared layout escapes copy and embeds the app background and bird portrait', async () => {
const mail = await buildEmailLayout({
backgroundUrl: 'https://flockpal.test/email-background.png',
eyebrow: 'Medication <reminder>', headline: 'Medication time for Kiwi & Peep',
contentHtml: '<p>Morning at 8:00 AM</p>',
action: { url: 'https://flockpal.test/?a=1&b=2', label: 'Open <FlockPal>' },
bird: { id: 'bird-1', name: 'Kiwi & Peep', photo_data_url: null, photo_object_key: null },
});
const html = String(mail.html);
assert.match(html, /Medication &lt;reminder&gt;/);
assert.match(html, /Medication time for Kiwi &amp; Peep/);
assert.match(html, /a=1&amp;b=2/);
assert.match(html, /Open &lt;FlockPal&gt;/);
assert.match(html, /Morning at 8:00 AM/);
assert.match(html, /background="https:\/\/flockpal.test\/email-background.png"/);
assert.match(html, /background-image:url\('https:\/\/flockpal.test\/email-background.png'\)/);
assert.doesNotMatch(html, /flockpal-pattern|cid:flockpal-background/);
assert.doesNotMatch(html, /48-hour|weights are overdue|data:image/);
for (const attachment of mail.attachments ?? []) {
assert.ok(html.includes(`cid:${attachment.cid}`));
assert.equal(attachment.contentDisposition, 'inline');
assert.ok(Buffer.isBuffer(attachment.content));
}
assert.equal((html.match(/<table\b/g) ?? []).length, (html.match(/<\/table>/g) ?? []).length);
assert.equal((html.match(/<td\b/g) ?? []).length, (html.match(/<\/td>/g) ?? []).length);
});
test('transactional layout omits optional portrait, action and reminder footer', async () => {
const mail = await buildEmailLayout({ eyebrow: 'Rescue status', headline: 'Flock update', contentHtml: '<p>Status changed</p>' });
assert.equal(mail.attachments?.length, 1);
assert.doesNotMatch(String(mail.html), /bird-portrait|<a |48-hour/);
});
-48
View File
@@ -1,48 +0,0 @@
import { readFile } from 'node:fs/promises';
import type { SendMailOptions } from 'nodemailer';
import sharp from 'sharp';
import { loadWeightReminderPortrait } from './birdPortrait.js';
export const escapeHtml = (value: string) => value.replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[char]!);
const asset = (name: string) => new URL(`../../assets/${name}`, import.meta.url);
export const buildEmailLayout = async ({ eyebrow, headline, preheader = headline, contentHtml, action, footer, bird, backgroundUrl = process.env.EMAIL_BACKGROUND_URL || new URL('/email-background.png', process.env.FRONTEND_URL || 'http://localhost:3000').href }: {
eyebrow: string;
headline: string;
preheader?: string;
/** Trusted markup; escape all dynamic values before passing them. */
contentHtml: string;
action?: { url: string; label: string };
footer?: string;
backgroundUrl?: string;
bird?: { id: string; name: string; photo_data_url: string | null; photo_object_key: string | null };
}): Promise<Pick<SendMailOptions, 'html' | 'attachments'>> => ({
attachments: [
{ filename: 'flockpal-logo.png', cid: 'flockpal-logo', contentType: 'image/png', contentDisposition: 'inline', content: await sharp(await readFile(asset('flockpal-logo.png'))).resize({ width: 480 }).png().toBuffer() },
...(bird ? [{ filename: 'bird-portrait.jpg', cid: 'bird-portrait', contentType: 'image/jpeg', contentDisposition: 'inline' as const, content: await loadWeightReminderPortrait({ bird_id: bird.id, photo_data_url: bird.photo_data_url, photo_object_key: bird.photo_object_key }) }] : []),
],
html: `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head>
<body style="margin:0;padding:0;background-color:#fef5e7;">
<div style="display:none;max-height:0;overflow:hidden;mso-hide:all;">${escapeHtml(preheader)}</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="width:100%;background-color:#fef5e7;font-family:Arial,sans-serif;color:#1f2a2a;line-height:1.5;">
<tr><td align="center" background="${escapeHtml(backgroundUrl)}" style="padding:24px 12px;background-color:#fef5e7;background-image:url('${escapeHtml(backgroundUrl)}');background-position:center top;background-repeat:repeat;background-size:100% auto;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:600px;background-color:#edf8ef;border:1px solid #d3e5d8;border-radius:24px;">
<tr><td align="center" style="padding:24px;border-bottom:1px solid #d3e5d8;">
<img src="cid:flockpal-logo" width="220" alt="FlockPal" style="display:block;width:220px;max-width:100%;height:auto;border:0;" />
</td></tr>
<tr><td style="padding:24px;">
<p style="margin:0 0 8px;font-size:12px;font-weight:bold;letter-spacing:1.5px;color:#238a5a;text-transform:uppercase;">${escapeHtml(eyebrow)}</p>
<h1 style="margin:0 0 12px;font-size:26px;line-height:1.2;word-break:break-word;">${escapeHtml(headline)}</h1>
${bird ? `<img src="cid:bird-portrait" width="76" height="76" alt="${escapeHtml(bird.name)}" style="display:block;border:0;border-radius:16px;margin:0 0 16px;" />` : ''}
${contentHtml}
${action ? `<table role="presentation" cellspacing="0" cellpadding="0" style="margin-top:24px;"><tr><td bgcolor="#238a5a" style="border-radius:24px;text-align:center;"><a href="${escapeHtml(action.url)}" style="display:inline-block;padding:13px 22px;border:1px solid #238a5a;border-radius:24px;color:#ffffff;font-size:16px;font-weight:bold;text-decoration:none;">${escapeHtml(action.label)}</a></td></tr></table>` : ''}
${footer ? `<p style="margin:20px 0 0;font-size:13px;color:#52645b;">${escapeHtml(footer)}</p>` : ''}
</td></tr>
</table>
<p style="margin:16px 0 0;color:#52645b;font-size:12px;">FlockPal · A little care, every day.</p>
</td></tr>
</table>
</body></html>`,
});
@@ -1,60 +0,0 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';
import sharp from 'sharp';
import { buildWeightReminderEmail, loadWeightReminderPortrait } from './weightReminderEmail.js';
import type { WeightReminder } from '../reminders/weightReminders.js';
const bird: WeightReminder = {
bird_id: 'bird-1', workspace_id: 1, bird_name: 'Kiwi & <Peep>', species: 'Cockatiel',
workspace_name: 'Our <Flock>', activity_at: '2026-09-01T12:00:00Z', recipient: 'owner@example.test',
photo_data_url: null, photo_object_key: null,
};
test('branded grouped email embeds distinct portraits and escapes dynamic content', async () => {
const mail = await buildWeightReminderEmail([bird, { ...bird, bird_id: 'bird-2', bird_name: 'Yoda' }], 'https://flockpal.test/?a=1&b=2');
assert.equal(mail.attachments?.length, 3);
assert.deepEqual(mail.attachments?.map(a => a.cid), ['flockpal-logo', 'weight-bird-0', 'weight-bird-1']);
for (const attachment of mail.attachments ?? []) {
assert.equal(attachment.contentDisposition, 'inline');
assert.ok(Buffer.isBuffer(attachment.content));
assert.ok(String(mail.html).includes(`cid:${attachment.cid}`));
}
assert.match(String(mail.html), /Kiwi &amp; &lt;Peep&gt;/);
assert.match(String(mail.html), /Our &lt;Flock&gt;/);
assert.match(String(mail.html), /a=1&amp;b=2/);
assert.match(String(mail.text), /Kiwi & <Peep>/);
});
test('database portrait is resized and invalid photos fall back to the default', async () => {
const source = await readFile(new URL('../../assets/yoda.png', import.meta.url));
const photo = await loadWeightReminderPortrait({ ...bird, photo_data_url: `data:image/png;base64,${source.toString('base64')}` });
const metadata = await sharp(photo).metadata();
assert.equal(metadata.width, 192);
assert.equal(metadata.height, 192);
assert.equal(metadata.format, 'jpeg');
assert.deepEqual(await loadWeightReminderPortrait({ ...bird, photo_data_url: 'data:image/png;base64,broken' }), await loadWeightReminderPortrait(bird));
});
test('private S3 portrait is downloaded and embedded rather than linked', async (t) => {
const env = { IMAGE_STORAGE_PROVIDER: 's3', S3_ENDPOINT: 'https://storage.example.test', S3_REGION: 'us-east-1', S3_BUCKET: 'portraits', S3_ACCESS_KEY_ID: 'test', S3_SECRET_ACCESS_KEY: 'test' };
const previous = Object.fromEntries(Object.keys(env).map(key => [key, process.env[key]]));
Object.assign(process.env, env);
try {
const source = await readFile(new URL('../../assets/yoda.png', import.meta.url));
t.mock.method(globalThis, 'fetch', async (url: string) => {
assert.match(String(url), /private-bird.png/);
return new Response(source);
});
const photo = await loadWeightReminderPortrait({ ...bird, photo_object_key: 'private-bird.png' });
assert.equal((await sharp(photo).metadata()).width, 192);
t.mock.restoreAll();
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 }));
assert.deepEqual(await loadWeightReminderPortrait({ ...bird, photo_object_key: 'missing.png' }), await loadWeightReminderPortrait(bird));
} finally {
for (const [key, value] of Object.entries(previous)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
-44
View File
@@ -1,44 +0,0 @@
import { buildEmailLayout, escapeHtml } from './emailLayout.js';
import type { SendMailOptions } from 'nodemailer';
import type { WeightReminder } from '../reminders/weightReminders.js';
import { loadWeightReminderPortrait } from './birdPortrait.js';
export { loadWeightReminderPortrait } from './birdPortrait.js';
export const buildWeightReminderEmail = async (
reminders: WeightReminder[],
frontendUrl: string,
loadPortrait = loadWeightReminderPortrait,
): Promise<Pick<SendMailOptions, 'subject' | 'text' | 'html' | 'attachments'>> => {
if (!reminders.length) throw new Error('A weight reminder email requires at least one bird');
const flock = reminders[0].workspace_name;
const attachments: NonNullable<SendMailOptions['attachments']> = [];
const rows: string[] = [];
for (const [index, bird] of reminders.entries()) {
const cid = `weight-bird-${index}`;
attachments.push({ filename: `${cid}.jpg`, cid, content: await loadPortrait(bird), contentType: 'image/jpeg', contentDisposition: 'inline' });
rows.push(`<tr>
<td width="88" style="padding:16px 12px 16px 0;border-bottom:1px solid #d3e5d8;vertical-align:middle;">
<img src="cid:${cid}" width="76" height="76" alt="${escapeHtml(bird.bird_name)}" style="display:block;border:0;border-radius:16px;" />
</td>
<td style="padding:16px 0;border-bottom:1px solid #d3e5d8;vertical-align:middle;word-break:break-word;">
<p style="margin:0 0 4px;font-size:18px;font-weight:bold;color:#1f2a2a;">${escapeHtml(bird.bird_name)}</p>
<p style="margin:0;font-size:14px;color:#52645b;">${escapeHtml(bird.species)}</p>
<p style="margin:6px 0 0;font-size:13px;color:#63562d;">Ready for a weight check</p>
</td>
</tr>`);
}
const layout = await buildEmailLayout({
eyebrow: 'Daily weight reminder',
headline: 'A little check-in for your flock',
preheader: `A little check-in for ${flock}: it's time to log your birds' weights.`,
contentHtml: `<p style="margin:0 0 16px;font-size:16px;">These birds in <strong>${escapeHtml(flock)}</strong> haven't had a new weight entry in at least 48 hours.</p><table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="border-collapse:collapse;table-layout:fixed;">${rows.join('')}</table>`,
action: { url: frontendUrl, label: 'Record their weights' },
footer: "You'll receive a daily reminder while weights are overdue. A new weight entry restarts the 48-hour clock.",
});
return {
subject: `Weight reminders for ${flock}`,
text: `Daily weight reminder — ${flock}\n\nThese birds haven't had a new weight entry in at least 48 hours:\n\n${reminders.map(bird => `- ${bird.bird_name} (${bird.species})`).join('\n')}\n\nOpen FlockPal to record their weights: ${frontendUrl}\n\nYou'll receive a daily reminder while weights are overdue.`,
...layout,
attachments: [...(layout.attachments ?? []), ...attachments],
};
};
+54
View File
@@ -0,0 +1,54 @@
import { db } from './db/client.js';
import { closeBirdMilestoneReminderQueue, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
const timeoutMs = Number(process.env.HEALTHCHECK_TIMEOUT_MS ?? 5_000);
const withTimeout = async <T>(operation: Promise<T>, label: string): Promise<T> => {
let timeout: NodeJS.Timeout | undefined;
try {
return await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
}),
]);
} finally {
if (timeout) {
clearTimeout(timeout);
}
}
};
const checkHttp = async (path: string) => {
const port = process.env.PORT ?? '5000';
const response = await withTimeout(fetch(`http://127.0.0.1:${port}${path}`), path);
if (!response.ok) {
throw new Error(`${path} returned ${response.status}`);
}
};
const checkWorkerDependencies = async () => {
await withTimeout(db.query('SELECT 1'), 'postgres');
await withTimeout(getBirdMilestoneReminderQueueCounts(), 'redis');
};
const mode = process.argv[2] ?? 'api-ready';
try {
if (mode === 'api-live') {
await checkHttp('/api/health/live');
} else if (mode === 'api-ready') {
await checkHttp('/api/health/ready');
} else if (mode === 'worker') {
await checkWorkerDependencies();
} else {
throw new Error(`Unknown healthcheck mode: ${mode}`);
}
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
} finally {
await Promise.allSettled([closeBirdMilestoneReminderQueue(), db.close()]);
}
-42
View File
@@ -1,42 +0,0 @@
import { Queue } from 'bullmq';
import { getReminderDate } from '../reminders/reminderDate.js';
import { redisConnection } from './redisConnection.js';
export const weightReminderQueueName = 'weight-reminders';
export const weightReminderQueue = new Queue(weightReminderQueueName, {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 60_000 },
removeOnComplete: 100,
removeOnFail: 1000,
},
});
export const enqueueDailyWeightReminder = (now = new Date()) => {
const runDate = getReminderDate(now);
return weightReminderQueue.add('check-overdue-weights', { runDate }, {
jobId: `weight-reminders-${runDate}`,
});
};
export const startWeightReminderScheduler = async () => {
// Remove the previous five-minute schedule during upgrades and when disabled.
await weightReminderQueue.removeJobScheduler('weight-reminders');
if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') return () => {};
await weightReminderQueue.setGlobalConcurrency(1);
let lastRunDate = '';
const runIfNeeded = async () => {
const now = new Date();
const runDate = getReminderDate(now);
if (lastRunDate === runDate) return;
await enqueueDailyWeightReminder(now);
lastRunDate = runDate;
};
const check = () => {
void runIfNeeded().catch(error => console.error('Weight reminder scheduler failed', error));
};
const startup = setTimeout(check, 15_000);
const interval = setInterval(check, 60 * 60_000);
return () => { clearTimeout(startup); clearInterval(interval); };
};
@@ -1,21 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { getReminderDate } from './reminderDate.js';
test('daily reminder dates use the configured zone across midnight and DST', () => {
const previous = process.env.MILESTONE_REMINDER_TIME_ZONE;
try {
process.env.MILESTONE_REMINDER_TIME_ZONE = 'America/New_York';
assert.equal(getReminderDate(new Date('2026-09-06T03:59:59Z')), '2026-09-05');
assert.equal(getReminderDate(new Date('2026-09-06T04:00:00Z')), '2026-09-06');
assert.equal(getReminderDate(new Date('2026-03-08T06:59:59Z')), '2026-03-08');
assert.equal(getReminderDate(new Date('2026-03-08T07:00:00Z')), '2026-03-08');
assert.equal(getReminderDate(new Date('2026-11-01T05:30:00Z')), '2026-11-01');
assert.equal(getReminderDate(new Date('2026-11-01T06:30:00Z')), '2026-11-01');
process.env.MILESTONE_REMINDER_TIME_ZONE = 'UTC';
assert.equal(getReminderDate(new Date('2026-09-06T03:59:59Z')), '2026-09-06');
} finally {
if (previous === undefined) delete process.env.MILESTONE_REMINDER_TIME_ZONE;
else process.env.MILESTONE_REMINDER_TIME_ZONE = previous;
}
});
-5
View File
@@ -1,5 +0,0 @@
export const getReminderTimeZone = () => process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
export const getReminderDate = (now = new Date()) => new Intl.DateTimeFormat('en-CA', {
timeZone: getReminderTimeZone(), year: 'numeric', month: '2-digit', day: '2-digit',
}).format(now);
@@ -1,61 +0,0 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { runWeightReminders, type WeightReminder } from './weightReminders.js';
const bird = (id: string, workspace = 1, recipient = 'owner@example.com'): WeightReminder => ({
species: 'Cockatiel', photo_data_url: null, photo_object_key: null,
bird_id: id, workspace_id: workspace, recipient,
bird_name: id, workspace_name: `Flock ${workspace}`, activity_at: '2026-09-01T12:00:00Z',
});
test('groups overdue birds into one email per flock and recipient', async () => {
const sent: WeightReminder[][] = [];
const finished: boolean[] = [];
const result = await runWeightReminders(async (birds) => { sent.push(birds); return true; }, {
list: async () => [bird('a'), bird('b'), bird('c', 2), bird('a', 1, 'caregiver@example.com')],
claim: async () => true,
finish: async (_token, delivered) => { finished.push(delivered); },
});
assert.deepEqual(sent.map((group) => group.map((item) => item.bird_id)), [['a', 'b'], ['c'], ['a']]);
assert.deepEqual(finished, [true, true, true, true]);
assert.equal(result.sent, 3);
});
test('omits birds whose claim was lost or whose weight changed', async () => {
const sent: WeightReminder[][] = [];
await runWeightReminders(async (birds) => { sent.push(birds); return true; }, {
list: async () => [bird('a'), bird('b')],
claim: async (reminder) => reminder.bird_id === 'b', finish: async () => {},
});
assert.deepEqual(sent.map((group) => group.map((item) => item.bird_id)), [['b']]);
});
test('does not email when all claims are unavailable', async () => {
await runWeightReminders(async () => { assert.fail('Unexpected email'); }, {
list: async () => [bird('a')], claim: async () => false, finish: async () => {},
});
});
test('missing SMTP releases all claims without recording delivery', async () => {
const finished: boolean[] = [];
const result = await runWeightReminders(async () => false, {
list: async () => [bird('a'), bird('b')], claim: async () => true,
finish: async (_token, delivered) => { finished.push(delivered); },
});
assert.deepEqual(finished, [false, false]);
assert.equal(result.sent, 0);
});
test('SMTP failure releases claims and still processes other flocks', async () => {
const finished: boolean[] = [];
const result = await runWeightReminders(async (birds) => {
if (birds[0].workspace_id === 1) throw new Error('SMTP unavailable');
return true;
}, {
list: async () => [bird('a'), bird('b'), bird('c', 2)], claim: async () => true,
finish: async (_token, delivered) => { finished.push(delivered); },
});
assert.deepEqual(finished, [false, false, true]);
assert.equal(result.sent, 1);
assert.equal(result.failed, 1);
});
-126
View File
@@ -1,126 +0,0 @@
import { getReminderTimeZone } from './reminderDate.js';
import { randomUUID } from 'node:crypto';
import { db } from '../db/client.js';
export type WeightReminder = {
bird_id: string;
workspace_id: number;
bird_name: string;
species: string;
photo_data_url: string | null;
photo_object_key: string | null;
workspace_name: string;
activity_at: string;
recipient: string;
};
// Use entry creation time: recorded_on is a date and cannot measure elapsed hours.
// Editing an existing entry does not reset the reminder clock.
export const listDueWeightReminders = async () => {
const result = await db.query<WeightReminder>(`
WITH activity AS (
SELECT birds.id AS bird_id, birds.workspace_id, birds.name AS bird_name,
workspaces.name AS workspace_name,
GREATEST(birds.created_at, COALESCE(MAX(weight_records.created_at), birds.created_at)) AS activity_at
FROM birds
JOIN workspaces ON workspaces.id = birds.workspace_id
LEFT JOIN weight_records ON weight_records.bird_id = birds.id
WHERE birds.memorialized_at IS NULL
GROUP BY birds.id, workspaces.name
), recipients AS (
SELECT DISTINCT workspace_id, LOWER(TRIM(users.email)) AS recipient
FROM workspace_members
JOIN users ON users.id = workspace_members.user_id
WHERE accepted_at IS NOT NULL
AND role IN ('owner', 'assistant', 'caregiver')
)
SELECT activity.bird_id, activity.workspace_id, activity.bird_name, activity.workspace_name,
activity.activity_at::text, recipients.recipient, birds.species, birds.photo_data_url, birds.photo_object_key
FROM activity
JOIN recipients USING (workspace_id)
JOIN birds ON birds.id = activity.bird_id
WHERE activity_at <= CURRENT_TIMESTAMP - INTERVAL '48 hours'
AND recipients.recipient <> ''
AND NOT EXISTS (
SELECT 1 FROM weight_reminder_deliveries d
WHERE d.bird_id = activity.bird_id AND d.workspace_id = activity.workspace_id
AND d.activity_at = activity.activity_at AND d.recipient = recipients.recipient
AND ((d.delivered_at AT TIME ZONE $1)::date >= (CURRENT_TIMESTAMP AT TIME ZONE $1)::date
OR (d.delivered_at IS NULL AND d.claimed_at > CURRENT_TIMESTAMP - INTERVAL '15 minutes'))
)
ORDER BY activity.workspace_id, activity.bird_id, recipients.recipient
`, [getReminderTimeZone()]);
return result.rows;
};
export const claimWeightReminder = async (reminder: WeightReminder, token: string) => {
const result = await db.query(`
INSERT INTO weight_reminder_deliveries (bird_id, workspace_id, activity_at, recipient, claim_token)
SELECT $1, $2, $3, $4, $5
WHERE EXISTS (
SELECT 1 FROM birds
WHERE id = $1 AND workspace_id = $2 AND memorialized_at IS NULL
AND GREATEST(created_at, COALESCE((SELECT MAX(created_at) FROM weight_records WHERE bird_id = $1), created_at)) = $3::timestamptz
)
ON CONFLICT (bird_id, workspace_id, activity_at, recipient) DO UPDATE
SET claim_token = EXCLUDED.claim_token, claimed_at = CURRENT_TIMESTAMP, delivered_at = NULL
WHERE (weight_reminder_deliveries.delivered_at AT TIME ZONE $6)::date < (CURRENT_TIMESTAMP AT TIME ZONE $6)::date
OR (weight_reminder_deliveries.delivered_at IS NULL
AND weight_reminder_deliveries.claimed_at <= CURRENT_TIMESTAMP - INTERVAL '15 minutes')
RETURNING bird_id
`, [reminder.bird_id, reminder.workspace_id, reminder.activity_at, reminder.recipient, token, getReminderTimeZone()]);
return Boolean(result.rowCount);
};
export const finishWeightReminder = async (token: string, delivered: boolean) => {
if (delivered) {
await db.query('UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP WHERE claim_token = $1', [token]);
} else {
await db.query('DELETE FROM weight_reminder_deliveries WHERE claim_token = $1 AND delivered_at IS NULL', [token]);
}
};
type Dependencies = {
list: typeof listDueWeightReminders;
claim: typeof claimWeightReminder;
finish: typeof finishWeightReminder;
};
export const runWeightReminders = async (
send: (reminders: WeightReminder[]) => Promise<boolean>,
dependencies: Dependencies = { list: listDueWeightReminders, claim: claimWeightReminder, finish: finishWeightReminder },
) => {
const reminders = await dependencies.list();
const result = { checked: reminders.length, sent: 0, skipped: 0, failed: 0 };
const groups = new Map<string, WeightReminder[]>();
for (const reminder of reminders) {
const key = JSON.stringify([reminder.workspace_id, reminder.recipient]);
groups.set(key, [...(groups.get(key) ?? []), reminder]);
}
for (const group of groups.values()) {
const claimed: { reminder: WeightReminder; token: string }[] = [];
let delivered = false;
try {
for (const reminder of group) {
const token = randomUUID();
if (await dependencies.claim(reminder, token)) claimed.push({ reminder, token });
else result.skipped += 1;
}
if (!claimed.length) continue;
delivered = await send(claimed.map(({ reminder }) => reminder));
for (const { token } of claimed) await dependencies.finish(token, delivered);
if (delivered) result.sent += 1;
else result.skipped += claimed.length;
} catch (error) {
result.failed += 1;
// Keep leases if SMTP accepted the email but saving delivery failed.
if (!delivered) {
for (const { token } of claimed) {
await dependencies.finish(token, false).catch((cleanupError) => console.error('Weight reminder claim cleanup failed', cleanupError));
}
}
console.error(`Weight reminder failed for flock ${group[0].workspace_id}`, error);
}
}
return result;
};
@@ -6,7 +6,9 @@ import {
createBird,
createPendingBirdTransfer,
getBirdById,
getOpenBirdTransferCode,
listWeightsForBird,
markBirdTransferCodeCompleted,
transferBirdToWorkspace,
} from './birdRepository.js';
import { mockDb } from '../test/mockDb.js';
@@ -253,3 +255,25 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
null,
]);
});
test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
const { calls } = mockDb({ rowCount: 0, rows: [] });
const transferCode = await getOpenBirdTransferCode('ADOPT-123');
assert.equal(transferCode, null);
assert.deepEqual(calls[0].params, ['ADOPT-123']);
assert.match(calls[0].text, /bird_transfer_codes\.completed_at IS NULL/);
assert.match(calls[0].text, /bird_transfer_codes\.revoked_at IS NULL/);
assert.match(calls[0].text, /birds\.workspace_id = bird_transfer_codes\.source_workspace_id/);
});
test('markBirdTransferCodeCompleted consumes a code for the receiving workspace', async () => {
const { calls } = mockDb({ rowCount: 1, rows: [] });
await markBirdTransferCodeCompleted('code-1', 22);
assert.deepEqual(calls[0].params, ['code-1', 22]);
assert.match(calls[0].text, /SET completed_at = CURRENT_TIMESTAMP/);
assert.match(calls[0].text, /completed_workspace_id = \$2/);
});
@@ -1,153 +0,0 @@
import { db } from '../db/client.js';
import type { DailyEducationQuestion, DailyEducationRow, EducationQuestionRow } from '../types.js';
export const getEducationOptOut = async (userId: string) => {
const result = await db.query<{ education_opt_out: boolean }>(
`SELECT education_opt_out
FROM users
WHERE id = $1`,
[userId],
);
return result.rows[0]?.education_opt_out ?? false;
};
export const updateEducationOptOut = async (userId: string, educationOptOut: boolean) => {
const result = await db.query<{ education_opt_out: boolean }>(
`UPDATE users
SET education_opt_out = $2
WHERE id = $1
RETURNING education_opt_out`,
[userId, educationOptOut],
);
return result.rows[0]?.education_opt_out ?? educationOptOut;
};
export const getDailyEducationForDate = async (publishDate?: string) => {
const result = publishDate
? await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
WHERE publish_date = $1`,
[publishDate],
)
: await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
WHERE publish_date = CURRENT_DATE`,
);
return result.rows[0] ?? null;
};
export const listDailyEducationForAdmin = async () => {
const result = await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
ORDER BY publish_date DESC
LIMIT 120`,
);
return result.rows;
};
export const upsertDailyEducation = async ({
publishDate,
fact,
createdByUserId,
}: {
publishDate: string;
fact: string;
createdByUserId: string;
}) => {
const result = await db.query<DailyEducationRow>(
`INSERT INTO daily_education (publish_date, fact, created_by_user_id)
VALUES ($1, $2, $3)
ON CONFLICT (publish_date) DO UPDATE
SET fact = EXCLUDED.fact,
updated_at = CURRENT_TIMESTAMP
RETURNING id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at`,
[publishDate, fact, createdByUserId],
);
return result.rows[0];
};
export const listEducationQuestionsForAdmin = async () => {
const result = await db.query<EducationQuestionRow>(
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
FROM education_question_bank
ORDER BY updated_at DESC, created_at DESC
LIMIT 400`,
);
return result.rows;
};
export const listDailyEducationQuestions = async (seedDate?: string) => {
const result = await db.query<EducationQuestionRow>(
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
FROM education_question_bank
ORDER BY md5(COALESCE($1::text, CURRENT_DATE::text) || id::text)
LIMIT 4`,
[seedDate ?? null],
);
return result.rows;
};
export const createEducationQuestion = async ({
question,
createdByUserId,
}: {
question: DailyEducationQuestion;
createdByUserId: string;
}) => {
const result = await db.query<EducationQuestionRow>(
`INSERT INTO education_question_bank (prompt, options, correct_answer_index, explanation, created_by_user_id)
VALUES ($1, $2::jsonb, $3, $4, $5)
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
[question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation, createdByUserId],
);
return result.rows[0];
};
export const updateEducationQuestion = async (questionId: string, question: DailyEducationQuestion) => {
const result = await db.query<EducationQuestionRow>(
`UPDATE education_question_bank
SET prompt = $2,
options = $3::jsonb,
correct_answer_index = $4,
explanation = $5,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
[questionId, question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation],
);
return result.rows[0] ?? null;
};
export const deleteEducationQuestion = async (questionId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM education_question_bank
WHERE id = $1
RETURNING id`,
[questionId],
);
return Boolean(result.rowCount);
};
export const deleteDailyEducation = async (educationId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM daily_education
WHERE id = $1
RETURNING id`,
[educationId],
);
return Boolean(result.rowCount);
};
-29
View File
@@ -13,38 +13,9 @@ export type UserRow = {
email: string;
password_hash: string | null;
name: string;
education_opt_out?: boolean;
created_at: string;
};
export type DailyEducationQuestion = {
prompt: string;
options: string[];
correctAnswerIndex: number;
explanation: string | null;
};
export type DailyEducationRow = {
id: string;
publish_date: string;
fact: string;
quiz_questions: DailyEducationQuestion[];
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
export type EducationQuestionRow = {
id: string;
prompt: string;
options: string[];
correct_answer_index: number;
explanation: string | null;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
export type WorkspaceRow = {
id: number;
name: string;
-18
View File
@@ -1,11 +1,8 @@
import { runWeightReminders } from './reminders/weightReminders.js';
import { weightReminderQueue, weightReminderQueueName, startWeightReminderScheduler } from './queues/weightReminderQueue.js';
import { Worker } from 'bullmq';
import { ensureSchema } from './db/schema.js';
import { db } from './db/client.js';
import {
sendWeightReminderNotification,
runBirdMilestoneReminders,
runMedicationReminders,
startBirdMilestoneReminderScheduler,
@@ -32,8 +29,6 @@ import {
import { redisConnection } from './queues/redisConnection.js';
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
let stopWeightReminderScheduler: (() => void) | undefined;
let weightReminderWorker: Worker | null = null;
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | null = null;
@@ -98,16 +93,6 @@ const startWorker = async () => {
console.error(`Adoption report job failed: id=${job?.id ?? 'unknown'}, birdId=${job?.data.birdId ?? 'unknown'}`, error);
});
weightReminderWorker = new Worker(weightReminderQueueName, async () => {
if (process.env.WEIGHT_REMINDERS_ENABLED === 'false') return;
const result = await runWeightReminders(sendWeightReminderNotification);
console.log('Weight reminder job completed', result);
return result;
}, { connection: redisConnection, concurrency: 1 });
weightReminderWorker.on('failed', (job, error) => {
console.error(`Weight reminder job failed: id=${job?.id ?? 'unknown'}`, error);
});
stopWeightReminderScheduler = await startWeightReminderScheduler();
startBirdMilestoneReminderScheduler();
startMedicationReminderScheduler();
console.log('FlockPal worker started.');
@@ -115,9 +100,6 @@ const startWorker = async () => {
const shutdown = async (signal: string) => {
console.log(`FlockPal worker received ${signal}; shutting down.`);
stopWeightReminderScheduler?.();
await weightReminderWorker?.close();
await weightReminderQueue.close();
await birdMilestoneWorker?.close();
await medicationReminderWorker?.close();
await adoptionReportWorker?.close();
@@ -1,26 +0,0 @@
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: flockpal_test
POSTGRES_PASSWORD: isolated_test_password
POSTGRES_DB: flockpal_weight_test
ports:
- '127.0.0.1:25432:5432'
tmpfs:
- /var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U flockpal_test -d flockpal_weight_test']
interval: 1s
timeout: 3s
retries: 30
redis:
image: redis:7-alpine
command: ['redis-server', '--save', '', '--appendonly', 'no']
ports:
- '127.0.0.1:26379:6379'
mail:
image: axllent/mailpit:latest
ports:
- '127.0.0.1:21025:1025'
- '127.0.0.1:28025:8025'
@@ -1,182 +0,0 @@
// Run only against disposable local Postgres, Redis, and Mailpit services.
// See docs/WEIGHT_REMINDER_TESTING.md for the isolated Compose setup.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { fileURLToPath } from 'node:url';
Object.assign(process.env, {
POSTGRES_HOST: '127.0.0.1', POSTGRES_PORT: '25432', POSTGRES_DB: 'flockpal_weight_test',
POSTGRES_USER: 'flockpal_test', POSTGRES_PASSWORD: 'isolated_test_password',
REDIS_URL: 'redis://127.0.0.1:26379',
SMTP_HOST: '127.0.0.1', SMTP_PORT: '21025', SMTP_SECURE: 'false',
SMTP_USER: '', SMTP_PASS: '', SMTP_FROM_EMAIL: 'reminders@flockpal.test', SMTP_FROM_NAME: 'FlockPal Test',
FRONTEND_URL: 'http://flockpal.test', MILESTONE_REMINDERS_ENABLED: 'false',
MEDICATION_REMINDERS_ENABLED: 'false', WEIGHT_REMINDERS_ENABLED: 'true',
MILESTONE_REMINDER_TIME_ZONE: 'America/New_York',
});
const { db } = await import('../dist/db/client.js');
const { ensureSchema } = await import('../dist/db/schema.js');
const { getReminderDate } = await import('../dist/reminders/reminderDate.js');
const { listDueWeightReminders, claimWeightReminder, finishWeightReminder } = await import('../dist/reminders/weightReminders.js');
const { weightReminderQueue, startWeightReminderScheduler, enqueueDailyWeightReminder } = await import('../dist/queues/weightReminderQueue.js');
const workers = [];
let logs = '';
const poll = async (check, label) => {
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
if (await check()) return;
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error(`Timed out: ${label}\n${logs}`);
};
const messages = async () => (await (await fetch('http://127.0.0.1:28025/api/v1/messages')).json()).messages;
const startWorker = () => {
const child = spawn(process.execPath, [fileURLToPath(new URL('../dist/worker.js', import.meta.url))], {
cwd: '/tmp', env: process.env, stdio: ['ignore', 'pipe', 'pipe'],
});
child.stdout.on('data', data => { logs += data; });
child.stderr.on('data', data => { logs += data; });
workers.push(child);
};
const stopWorkers = async () => {
const exits = workers.map(child => once(child, 'exit'));
for (const child of workers) child.kill('SIGTERM');
await Promise.all(exits);
workers.length = 0;
};
const drain = async () => {
await poll(async () => {
const counts = await weightReminderQueue.getJobCounts('active', 'waiting');
return !counts.active && !counts.waiting;
}, 'queue drain');
};
const runJob = async () => {
const job = await weightReminderQueue.add('integration-check', {});
await poll(async () => ['completed', 'failed'].includes(await job.getState()), 'manual job');
assert.equal(await job.getState(), 'completed');
return (await weightReminderQueue.getJob(job.id)).returnvalue;
};
try {
await ensureSchema();
await ensureSchema();
assert.equal((await db.query('SELECT COUNT(*)::int AS count FROM birds')).rows[0].count, 0, 'Use a fresh disposable database');
assert.equal((await messages()).length, 0, 'Use a fresh email catcher');
await db.query("INSERT INTO workspaces (id, name) VALUES (101, 'Test Flock'), (102, 'Other Flock')");
const ids = {};
for (const [role, accepted] of [['owner', true], ['assistant', true], ['caregiver', true], ['viewer', true], ['pending', false]]) {
const id = randomUUID(); ids[role] = id;
await db.query('INSERT INTO users (id, email, name) VALUES ($1, $2, $3)', [id, `${role}@example.test`, role]);
await db.query(`INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at)
VALUES (101, $1, $2, $3, $4, ${accepted ? 'CURRENT_TIMESTAMP' : 'NULL'})`, [id, `${role}@example.test`, role, role === 'pending' ? 'caregiver' : role]);
}
await db.query("INSERT INTO workspace_members (workspace_id, user_id, invite_email, name, role, accepted_at) VALUES (102, $1, 'owner@example.test', 'owner', 'owner', CURRENT_TIMESTAMP)", [ids.owner]);
const birds = {};
for (const [name, age, memorial, workspace] of [
['Kiwi & <Peep>', 72, false, 101], ['Boundary', 48, false, 101],
['No weights', null, false, 101], ['Recent', 47, false, 101],
['Memorial', 72, true, 101], ['Other bird', 72, false, 102],
]) {
const id = randomUUID(); birds[name] = id;
await db.query(`INSERT INTO birds (id, workspace_id, name, species, created_at, memorialized_at)
VALUES ($1, $2, $3, 'Cockatiel', CURRENT_TIMESTAMP - INTERVAL '100 hours', ${memorial ? 'CURRENT_TIMESTAMP' : 'NULL'})`, [id, workspace, name]);
if (age !== null) await db.query("INSERT INTO weight_records (bird_id, weight_grams, recorded_on, created_at) VALUES ($1, 90, CURRENT_DATE - 3, CURRENT_TIMESTAMP - $2 * INTERVAL '1 hour')", [id, age]);
}
const portrait = await readFile(new URL('../assets/yoda.png', import.meta.url));
await db.query('UPDATE birds SET photo_data_url = $1 WHERE id = $2', [`data:image/png;base64,${portrait.toString('base64')}`, birds['Kiwi & <Peep>']]);
const due = await listDueWeightReminders();
assert.equal(due.length, 10);
assert.deepEqual(new Set(due.map(b => b.recipient)), new Set(['owner@example.test', 'assistant@example.test', 'caregiver@example.test']));
const candidate = due[0];
const tokens = [randomUUID(), randomUUID()];
const claims = await Promise.all(tokens.map(token => claimWeightReminder(candidate, token)));
assert.equal(claims.filter(Boolean).length, 1, 'Only one concurrent claim wins');
await finishWeightReminder(tokens[claims.indexOf(true)], false);
console.log('PASS: schema initialization is repeatable; eligibility, roles, and concurrent claims');
// Simulate the schedule present before upgrading to daily reminders.
await weightReminderQueue.upsertJobScheduler('weight-reminders', { every: 300_000 }, { name: 'check-overdue-weights', data: {} });
startWorker(); startWorker();
await poll(async () => (await messages()).length === 4, 'initial grouped emails');
await drain();
const initial = await messages();
const bodies = await Promise.all(initial.map(async message => (await fetch(`http://127.0.0.1:28025/api/v1/message/${message.ID}`)).json()));
for (const body of bodies) {
if (body.Subject === 'Weight reminders for Test Flock') {
for (const name of ['Kiwi & <Peep>', 'Boundary', 'No weights']) assert.ok(body.Text.includes(name));
assert.ok(body.HTML.includes('Kiwi &amp; &lt;Peep&gt;'));
assert.ok(!body.Text.includes('Memorial'));
assert.ok(!body.Text.includes('Recent'));
assert.ok(!body.Text.includes('Other bird'));
} else {
assert.equal(body.Subject, 'Weight reminders for Other Flock');
assert.ok(body.Text.includes('Other bird'));
assert.ok(!body.Text.includes('Boundary'));
}
assert.equal(body.To.length, 1);
const inline = body.Inline ?? [];
assert.ok(inline.some(attachment => attachment.ContentID === 'flockpal-logo'));
assert.equal(inline.filter(attachment => attachment.ContentID.startsWith('weight-bird-')).length, body.Subject === 'Weight reminders for Test Flock' ? 3 : 1);
assert.ok(body.HTML.includes('Daily weight reminder'));
assert.ok(body.HTML.includes('Record their weights'));
}
assert.equal((await weightReminderQueue.getJobSchedulers()).length, 0);
await poll(async () => Boolean(await weightReminderQueue.getJob(`weight-reminders-${getReminderDate()}`)), 'startup daily scheduler');
const dailyJobs = await Promise.all([enqueueDailyWeightReminder(), enqueueDailyWeightReminder()]);
assert.equal(dailyJobs[0].id, dailyJobs[1].id);
await drain();
assert.equal(await weightReminderQueue.getGlobalConcurrency(), 1);
assert.equal((await runJob()).sent, 0);
assert.equal((await messages()).length, 4);
console.log('PASS: two real workers, one daily job, legacy schedule removed, grouped SMTP delivery, escaping, flock isolation, no immediate duplicates');
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP");
assert.equal((await runJob()).sent, 0);
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = ((CURRENT_TIMESTAMP AT TIME ZONE 'America/New_York')::date::timestamp AT TIME ZONE 'America/New_York') - INTERVAL '1 second'");
assert.equal((await runJob()).sent, 4);
assert.equal((await messages()).length, 8);
await db.query("INSERT INTO weight_records (bird_id, weight_grams, recorded_on) VALUES ($1, 91, CURRENT_DATE)", [birds.Boundary]);
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'");
assert.ok((await listDueWeightReminders()).every(b => b.bird_id !== birds.Boundary));
assert.equal((await runJob()).sent, 4);
console.log('PASS: no repeat on the same local date, daily repeat, fresh weight resets eligibility');
await stopWorkers();
await db.query("UPDATE weight_reminder_deliveries SET delivered_at = CURRENT_TIMESTAMP - INTERVAL '49 hours'");
const beforeFailures = (await messages()).length;
process.env.SMTP_HOST = '';
startWorker();
assert.equal((await runJob()).sent, 0);
assert.equal((await listDueWeightReminders()).length, 7);
await stopWorkers();
process.env.SMTP_HOST = '127.0.0.1';
process.env.SMTP_PORT = '21026'; // No SMTP listener: exercise a real refused connection.
startWorker();
const failed = await runJob();
assert.equal(failed.failed, 4);
assert.equal(failed.sent, 0);
assert.equal((await listDueWeightReminders()).length, 7);
assert.equal((await messages()).length, beforeFailures);
await stopWorkers();
process.env.SMTP_PORT = '21025';
startWorker();
await runJob();
await poll(async () => (await messages()).length === beforeFailures + 4, 'delivery after SMTP recovery');
await drain();
await stopWorkers();
console.log('PASS: missing SMTP, real SMTP connection failure, released claims, successful retry');
process.env.WEIGHT_REMINDERS_ENABLED = 'false';
await startWeightReminderScheduler();
assert.equal((await weightReminderQueue.getJobSchedulers()).length, 0);
console.log('PASS: graceful worker shutdown and disabled scheduler removal');
console.log('All Postgres / Redis / SMTP integration checks passed.');
} finally {
for (const child of workers) child.kill('SIGKILL');
await weightReminderQueue.close();
await db.close();
}
+18 -2
View File
@@ -60,7 +60,6 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
@@ -98,6 +97,12 @@ services:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "dist/healthcheck.js", "api-ready"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
labels:
- traefik.enable=true
- traefik.docker.network=traefik
@@ -143,7 +148,6 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
SMTP_HOST: ${SMTP_HOST:-}
@@ -158,6 +162,12 @@ services:
condition: service_healthy
redis:
condition: service_healthy
healthcheck:
test: ["CMD", "node", "dist/healthcheck.js", "worker"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
frontend:
@@ -169,6 +179,12 @@ services:
container_name: flockpal-frontend
depends_on:
- backend
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
labels:
- traefik.enable=true
- traefik.docker.network=traefik
-2
View File
@@ -58,7 +58,6 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
@@ -136,7 +135,6 @@ services:
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
RESCUE_ONBOARDING_WEBHOOK_URL: ${RESCUE_ONBOARDING_WEBHOOK_URL:-https://n8n.blaishome.online/webhook/395cd538-5e0d-4e89-8070-9e66f571b7ee}
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
SMTP_HOST: ${SMTP_HOST:-}
+35 -2
View File
@@ -319,14 +319,47 @@ Validation failures return `400` with this shape:
#### `GET /api/health`
Public health check.
Public readiness-compatible health check. Verifies backend dependencies.
Response `200`:
```json
{ "ok": true }
{
"ok": true,
"service": "flockpal-backend",
"status": "ready",
"checkedAt": "2026-06-06T00:00:00.000Z",
"dependencies": {
"postgres": { "ok": true, "latencyMs": 3 },
"redis": { "ok": true, "latencyMs": 4 }
}
}
```
Response `503` when Postgres or Redis is unavailable.
#### `GET /api/health/live`
Public liveness check. Verifies the backend process is running without checking dependencies.
Response `200`:
```json
{
"ok": true,
"service": "flockpal-backend",
"status": "live",
"uptimeSeconds": 120,
"checkedAt": "2026-06-06T00:00:00.000Z"
}
```
#### `GET /api/health/ready`
Public readiness check. Verifies the backend can reach Postgres and Redis.
Response `200` uses the same shape as `GET /api/health`; response `503` means at least one dependency failed.
### Metrics
#### `GET /api/metrics`
-34
View File
@@ -1,34 +0,0 @@
# Weight reminder tests
From the repository root, run the unit tests and builds:
```bash
npm ci --prefix backend
npm ci --prefix frontend
npm test --prefix backend
npm run build --prefix backend
npm run build --prefix frontend
```
The integration test uses real Postgres 16, Redis 7, two worker processes, and
Mailpit to capture SMTP messages locally. It requires Docker and free localhost
ports 25432, 26379, 21025, and 28025. Port 21026 must remain unused for the SMTP
failure check. Test addresses use `.test`; Mailpit does not relay mail externally.
```bash
docker compose -p flockpal-weight-test -f backend/test-integration/compose.weight-reminders.yml up -d --wait
node backend/test-integration/weightReminders.mjs
docker compose -p flockpal-weight-test -f backend/test-integration/compose.weight-reminders.yml down --volumes
```
Always run the last command when finished, including after a failed test. Start
fresh containers for each run. Postgres data is disposable and held in tmpfs.
The test overrides database, Redis, and SMTP environment variables to use only
these local services. It does not use the application's configured SMTP account.
Coverage includes repeatable schema initialization, overdue and recent weights,
birds without weights, memorial exclusions, accepted care roles, concurrent
claims, one daily job across two workers, removal of the old five-minute schedule, grouped emails, embedded branding and portraits, HTML escaping, flock
isolation, daily repeats, fresh weight resets, SMTP failures and recovery,
worker shutdown, and disabling the scheduler. Repeat timing is tested by aging
delivery timestamps rather than waiting two days.
+6
View File
@@ -12,6 +12,12 @@ server {
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location = /healthz {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
location / {
try_files $uri $uri/ /index.html;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

+2 -567
View File
@@ -193,23 +193,6 @@ type AdminRescueWorkspace = {
memberCount: number;
};
type DailyEducationQuestion = {
id: string;
prompt: string;
options: string[];
correctAnswerIndex: number;
explanation: string | null;
};
type DailyEducation = {
id: string;
publishDate: string;
fact: string;
quizQuestions: DailyEducationQuestion[];
createdAt: string;
updatedAt: string;
};
type IntegrationTokenSummary = {
id: string;
userId: string;
@@ -409,18 +392,6 @@ type BillingNotice = {
message: string;
};
type DailyEducationQuestionFormState = {
prompt: string;
options: [string, string, string, string];
correctAnswerIndex: number;
explanation: string;
};
type DailyEducationFormState = {
publishDate: string;
fact: string;
};
type BulkWeightRowState = {
weightGrams: string;
};
@@ -943,18 +914,6 @@ const emptyFlockNoteForm: FlockNoteFormState = {
body: '',
};
const emptyDailyEducationQuestion = (): DailyEducationQuestionFormState => ({
prompt: '',
options: ['', '', '', ''],
correctAnswerIndex: 0,
explanation: '',
});
const emptyDailyEducationForm = (): DailyEducationFormState => ({
publishDate: new Date().toISOString().slice(0, 10),
fact: '',
});
const defaultAuthProviders: AuthProvider[] = [
{ providerKey: 'google', displayName: 'Google', enabled: false },
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
@@ -2173,20 +2132,6 @@ function App() {
const [birdTimelineEventForm, setBirdTimelineEventForm] = useState<BirdTimelineEventFormState>(emptyBirdTimelineEventForm);
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
const [adminDailyEducation, setAdminDailyEducation] = useState<DailyEducation[]>([]);
const [adminEducationQuestions, setAdminEducationQuestions] = useState<DailyEducationQuestion[]>([]);
const [dailyEducationForm, setDailyEducationForm] = useState<DailyEducationFormState>(emptyDailyEducationForm);
const [educationQuestionForm, setEducationQuestionForm] = useState<DailyEducationQuestionFormState>(emptyDailyEducationQuestion);
const [editingEducationQuestionId, setEditingEducationQuestionId] = useState('');
const [savingDailyEducation, setSavingDailyEducation] = useState(false);
const [savingEducationQuestion, setSavingEducationQuestion] = useState(false);
const [deletingDailyEducationId, setDeletingDailyEducationId] = useState('');
const [deletingEducationQuestionId, setDeletingEducationQuestionId] = useState('');
const [todayEducation, setTodayEducation] = useState<DailyEducation | null>(null);
const [educationOptOut, setEducationOptOut] = useState(false);
const [savingEducationPreference, setSavingEducationPreference] = useState(false);
const [educationAnswers, setEducationAnswers] = useState<Record<number, number>>({});
const [dailyEducationOpen, setDailyEducationOpen] = useState(false);
const [birds, setBirds] = useState<Bird[]>([]);
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
@@ -2797,15 +2742,6 @@ function App() {
setAuditLogEntries([]);
setAdminSummary(null);
setAdminRescueWorkspaces([]);
setAdminDailyEducation([]);
setAdminEducationQuestions([]);
setDailyEducationForm(emptyDailyEducationForm());
setEducationQuestionForm(emptyDailyEducationQuestion());
setEditingEducationQuestionId('');
setTodayEducation(null);
setEducationOptOut(false);
setEducationAnswers({});
setDailyEducationOpen(false);
setBirds([]);
setMemorializedBirds([]);
setWeights([]);
@@ -3105,27 +3041,20 @@ function App() {
const loadAdminDashboard = async () => {
try {
const [summaryResponse, rescuesResponse, educationResponse, educationQuestionsResponse] = await Promise.all([
const [summaryResponse, rescuesResponse] = await Promise.all([
apiFetch('/admin/summary', authToken),
apiFetch('/admin/rescue-workspaces', authToken),
apiFetch('/admin/daily-education', authToken),
apiFetch('/admin/education-questions', authToken),
]);
if (!summaryResponse.ok || !rescuesResponse.ok || !educationResponse.ok || !educationQuestionsResponse.ok) {
if (!summaryResponse.ok || !rescuesResponse.ok) {
throw new Error('Unable to load admin dashboard.');
}
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {};
const educationData = (await readJsonSafely<{ education?: DailyEducation[] }>(educationResponse)) ?? {};
const educationQuestionsData =
(await readJsonSafely<{ questions?: DailyEducationQuestion[] }>(educationQuestionsResponse)) ?? {};
setAdminSummary(summaryData.summary ?? null);
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
setAdminDailyEducation(educationData.education ?? []);
setAdminEducationQuestions(educationQuestionsData.questions ?? []);
} catch (adminError) {
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
}
@@ -3134,34 +3063,6 @@ function App() {
void loadAdminDashboard();
}, [activePage, authSession?.isAdmin, authToken]);
useEffect(() => {
if (!authToken || !authSession) {
return;
}
const loadTodayEducation = async () => {
try {
const response = await apiFetch('/education/today', authToken);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to load daily education.'));
}
const data =
(await readJsonSafely<{ education?: DailyEducation | null; educationOptOut?: boolean }>(response)) ?? {};
setEducationOptOut(Boolean(data.educationOptOut));
setTodayEducation(data.education ?? null);
setEducationAnswers({});
setDailyEducationOpen(false);
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to load daily education.');
}
};
void loadTodayEducation();
}, [authSession, authToken]);
useEffect(() => {
if (!selectedBird?.id) {
setWeights([]);
@@ -3682,221 +3583,6 @@ function App() {
}
};
const loadDailyEducationIntoForm = (education: DailyEducation) => {
setDailyEducationForm({
publishDate: education.publishDate,
fact: education.fact,
});
};
const loadEducationQuestionIntoForm = (question: DailyEducationQuestion) => {
setEditingEducationQuestionId(question.id);
setEducationQuestionForm({
prompt: question.prompt,
options: [
question.options[0] ?? '',
question.options[1] ?? '',
question.options[2] ?? '',
question.options[3] ?? '',
],
correctAnswerIndex: question.correctAnswerIndex,
explanation: question.explanation ?? '',
});
};
const resetEducationQuestionForm = () => {
setEditingEducationQuestionId('');
setEducationQuestionForm(emptyDailyEducationQuestion());
};
const handleDailyEducationSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!authToken) {
return;
}
setError('');
setSavingDailyEducation(true);
try {
const response = await apiFetch('/admin/daily-education', authToken, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
publishDate: dailyEducationForm.publishDate,
fact: dailyEducationForm.fact.trim(),
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save daily education.'));
}
const data = (await readJsonSafely<{ education?: DailyEducation }>(response)) ?? {};
if (!data.education) {
throw new Error('Unable to save daily education.');
}
setAdminDailyEducation((current) =>
[data.education!, ...current.filter((education) => education.id !== data.education!.id && education.publishDate !== data.education!.publishDate)]
.sort((left, right) => right.publishDate.localeCompare(left.publishDate)),
);
if (data.education.publishDate === new Date().toISOString().slice(0, 10) && !educationOptOut) {
const todayResponse = await apiFetch('/education/today', authToken);
const todayData = todayResponse.ok
? await readJsonSafely<{ education?: DailyEducation | null }>(todayResponse)
: null;
setTodayEducation(todayData?.education ?? null);
setEducationAnswers({});
}
setDailyEducationForm(emptyDailyEducationForm());
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to save daily education.');
} finally {
setSavingDailyEducation(false);
}
};
const handleEducationQuestionSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!authToken) {
return;
}
setError('');
setSavingEducationQuestion(true);
try {
const response = await apiFetch(
editingEducationQuestionId ? `/admin/education-questions/${editingEducationQuestionId}` : '/admin/education-questions',
authToken,
{
method: editingEducationQuestionId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: educationQuestionForm.prompt.trim(),
options: educationQuestionForm.options.map((option) => option.trim()),
correctAnswerIndex: educationQuestionForm.correctAnswerIndex,
explanation: educationQuestionForm.explanation.trim(),
}),
},
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save education question.'));
}
const data = (await readJsonSafely<{ question?: DailyEducationQuestion }>(response)) ?? {};
if (!data.question) {
throw new Error('Unable to save education question.');
}
setAdminEducationQuestions((current) => [
data.question!,
...current.filter((question) => question.id !== data.question!.id),
]);
resetEducationQuestionForm();
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to save education question.');
} finally {
setSavingEducationQuestion(false);
}
};
const handleDeleteEducationQuestion = async (questionId: string) => {
if (!authToken) {
return;
}
setError('');
setDeletingEducationQuestionId(questionId);
try {
const response = await apiFetch(`/admin/education-questions/${questionId}`, authToken, { method: 'DELETE' });
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to delete education question.'));
}
setAdminEducationQuestions((current) => current.filter((question) => question.id !== questionId));
if (editingEducationQuestionId === questionId) {
resetEducationQuestionForm();
}
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to delete education question.');
} finally {
setDeletingEducationQuestionId('');
}
};
const handleDeleteDailyEducation = async (educationId: string) => {
if (!authToken) {
return;
}
setError('');
setDeletingDailyEducationId(educationId);
try {
const response = await apiFetch(`/admin/daily-education/${educationId}`, authToken, { method: 'DELETE' });
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to delete daily education.'));
}
setAdminDailyEducation((current) => current.filter((education) => education.id !== educationId));
if (todayEducation?.id === educationId) {
setTodayEducation(null);
}
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to delete daily education.');
} finally {
setDeletingDailyEducationId('');
}
};
const handleEducationPreferenceChange = async (nextEducationOptOut: boolean) => {
if (!authToken) {
return;
}
setError('');
setSavingEducationPreference(true);
try {
const response = await apiFetch('/education/preferences', authToken, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ educationOptOut: nextEducationOptOut }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save education preference.'));
}
setEducationOptOut(nextEducationOptOut);
if (nextEducationOptOut) {
setTodayEducation(null);
setDailyEducationOpen(false);
} else {
const todayResponse = await apiFetch('/education/today', authToken);
const data = todayResponse.ok
? await readJsonSafely<{ education?: DailyEducation | null }>(todayResponse)
: null;
setTodayEducation(data?.education ?? null);
}
setEducationAnswers({});
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to save education preference.');
} finally {
setSavingEducationPreference(false);
}
};
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
@@ -6430,74 +6116,6 @@ function App() {
</div>
</section>
{todayEducation ? (
<article className={dailyEducationOpen ? 'panel daily-education-panel open' : 'panel daily-education-panel condensed'}>
<div className="panel-header">
<div>
<p className="eyebrow">Fid fact of the day</p>
<h2>{dailyEducationOpen ? formatDate(todayEducation.publishDate) : 'Daily learning'}</h2>
</div>
<button className="secondary-button" onClick={() => setDailyEducationOpen((current) => !current)} type="button">
{dailyEducationOpen ? 'Close' : 'Open'}
</button>
</div>
{dailyEducationOpen ? (
<>
<p className="daily-fact">{todayEducation.fact}</p>
<section className="daily-quiz" aria-label="Daily education quiz">
{todayEducation.quizQuestions.map((question, questionIndex) => {
const selectedAnswer = educationAnswers[questionIndex];
const hasAnswer = selectedAnswer !== undefined;
return (
<fieldset key={`${todayEducation.id}-${question.id}`} className="quiz-question">
<legend>{question.prompt}</legend>
<div className="quiz-options">
{question.options.map((option, optionIndex) => (
<label
key={`${question.id}-${optionIndex}`}
className={
hasAnswer && optionIndex === question.correctAnswerIndex
? 'quiz-option correct'
: hasAnswer && optionIndex === selectedAnswer
? 'quiz-option incorrect'
: 'quiz-option'
}
>
<input
type="radio"
name={`daily-question-${question.id}`}
checked={selectedAnswer === optionIndex}
onChange={() =>
setEducationAnswers((current) => ({
...current,
[questionIndex]: optionIndex,
}))
}
/>
<span>{option}</span>
</label>
))}
</div>
{hasAnswer ? (
<p className={selectedAnswer === question.correctAnswerIndex ? 'quiz-feedback correct' : 'quiz-feedback'}>
{selectedAnswer === question.correctAnswerIndex ? 'Correct.' : 'Correct answer shown.'}{' '}
{question.explanation}
</p>
) : null}
</fieldset>
);
})}
</section>
</>
) : (
<p className="muted daily-education-teaser">
Open today&apos;s fact and {todayEducation.quizQuestions.length || 'the'} quiz questions.
</p>
)}
</article>
) : null}
<section className="forms-grid">
<article className="panel form-panel pulse-panel">
<div className="panel-header">
@@ -6654,168 +6272,6 @@ function App() {
)}
</div>
</article>
<article className="panel admin-education-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Education</p>
<h2>Fid facts</h2>
</div>
<button className="secondary-button" onClick={() => setDailyEducationForm(emptyDailyEducationForm())} type="button">
New date
</button>
</div>
<form className="form-panel" onSubmit={handleDailyEducationSubmit}>
<div className="education-admin-basics">
<label>
Publish date
<input
type="date"
value={dailyEducationForm.publishDate}
onChange={(event) => setDailyEducationForm({ ...dailyEducationForm, publishDate: event.target.value })}
required
/>
</label>
<label>
Fid fact
<textarea
value={dailyEducationForm.fact}
onChange={(event) => setDailyEducationForm({ ...dailyEducationForm, fact: event.target.value })}
rows={3}
required
/>
</label>
</div>
<button className="primary-button" type="submit" disabled={savingDailyEducation}>
{savingDailyEducation ? 'Saving...' : 'Save fact'}
</button>
</form>
<div className="recent-list education-admin-list">
{adminDailyEducation.length ? (
adminDailyEducation.map((education) => (
<article key={education.id} className="vet-visit-card">
<strong>{formatDate(education.publishDate)}</strong>
<span>{education.fact}</span>
<div className="button-row">
<button className="secondary-button" type="button" onClick={() => loadDailyEducationIntoForm(education)}>
Edit
</button>
<button
className="secondary-button"
type="button"
onClick={() => handleDeleteDailyEducation(education.id)}
disabled={deletingDailyEducationId === education.id}
>
{deletingDailyEducationId === education.id ? 'Deleting...' : 'Delete'}
</button>
</div>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No scheduled facts yet</strong>
<small>Add a dated Fid fact for the overview page.</small>
</article>
)}
</div>
</article>
<article className="panel admin-education-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Education</p>
<h2>Quiz question bank</h2>
</div>
<button className="secondary-button" onClick={resetEducationQuestionForm} type="button">
New question
</button>
</div>
<p className="muted">Each day the quiz uses a stable random selection of four saved questions.</p>
<form className="form-panel" onSubmit={handleEducationQuestionSubmit}>
<fieldset className="settings-nested-card quiz-editor-question">
<legend>{editingEducationQuestionId ? 'Edit question' : 'Add question'}</legend>
<label>
Prompt
<input
value={educationQuestionForm.prompt}
onChange={(event) => setEducationQuestionForm({ ...educationQuestionForm, prompt: event.target.value })}
required
/>
</label>
<div className="quiz-editor-options">
{educationQuestionForm.options.map((option, optionIndex) => (
<label key={`bank-option-${optionIndex}`}>
Option {optionIndex + 1}
<input
value={option}
onChange={(event) => {
const options = [...educationQuestionForm.options] as DailyEducationQuestionFormState['options'];
options[optionIndex] = event.target.value;
setEducationQuestionForm({ ...educationQuestionForm, options });
}}
required
/>
</label>
))}
</div>
<label>
Correct option
<select
value={educationQuestionForm.correctAnswerIndex}
onChange={(event) =>
setEducationQuestionForm({ ...educationQuestionForm, correctAnswerIndex: Number(event.target.value) })
}
>
{educationQuestionForm.options.map((_, optionIndex) => (
<option key={`bank-correct-${optionIndex}`} value={optionIndex}>
Option {optionIndex + 1}
</option>
))}
</select>
</label>
<label>
Explanation
<textarea
value={educationQuestionForm.explanation}
onChange={(event) => setEducationQuestionForm({ ...educationQuestionForm, explanation: event.target.value })}
rows={2}
/>
</label>
</fieldset>
<button className="primary-button" type="submit" disabled={savingEducationQuestion}>
{savingEducationQuestion ? 'Saving...' : editingEducationQuestionId ? 'Save question changes' : 'Add question'}
</button>
</form>
<div className="recent-list education-admin-list">
{adminEducationQuestions.length ? (
adminEducationQuestions.map((question) => (
<article key={question.id} className="vet-visit-card">
<strong>{question.prompt}</strong>
<span>Answer: {question.options[question.correctAnswerIndex]}</span>
<small>{question.options.length} options</small>
<div className="button-row">
<button className="secondary-button" type="button" onClick={() => loadEducationQuestionIntoForm(question)}>
Edit
</button>
<button
className="secondary-button"
type="button"
onClick={() => handleDeleteEducationQuestion(question.id)}
disabled={deletingEducationQuestionId === question.id}
>
{deletingEducationQuestionId === question.id ? 'Deleting...' : 'Delete'}
</button>
</div>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No quiz questions yet</strong>
<small>Add at least four questions before the daily quiz can use a full set.</small>
</article>
)}
</div>
</article>
</section>
) : null}
@@ -8941,27 +8397,6 @@ function App() {
</div>
<div className="settings-column settings-column-right">
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Education</p>
<h2>Daily learning</h2>
</div>
</div>
<label className="toggle-card">
<input
type="checkbox"
checked={!educationOptOut}
onChange={(event) => handleEducationPreferenceChange(!event.target.checked)}
disabled={savingEducationPreference}
/>
<span>
<strong>Show daily education</strong>
<small>Display the Fid fact of the day and four-question quiz on Overview.</small>
</span>
</label>
</article>
<article className="panel form-panel settings-card-collaborators">
<div className="panel-header">
<div>
-589
View File
@@ -41,7 +41,6 @@ body,
#root {
margin: 0;
min-height: 100%;
width: 100%;
}
body {
@@ -49,8 +48,6 @@ body {
color: var(--ink);
position: relative;
isolation: isolate;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
}
body::before {
@@ -124,7 +121,6 @@ textarea {
.content-shell {
display: grid;
gap: 1.5rem;
min-width: 0;
}
.top-alert-notification {
@@ -197,13 +193,11 @@ textarea {
display: grid;
gap: 1rem;
align-self: start;
min-width: 0;
}
.side-nav {
display: grid;
gap: 1.25rem;
min-width: 0;
}
.brand-lockup {
@@ -459,7 +453,6 @@ textarea {
.stack-grid {
display: grid;
gap: 1.5rem;
min-width: 0;
}
.hero-card,
@@ -543,7 +536,6 @@ textarea {
.forms-grid {
display: grid;
gap: 1.5rem;
min-width: 0;
}
.hero-stats {
@@ -729,7 +721,6 @@ textarea {
border-radius: 28px;
padding: 1.5rem;
align-self: start;
min-width: 0;
}
.panel-header {
@@ -738,7 +729,6 @@ textarea {
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
min-width: 0;
}
.pulse-panel .panel-header {
@@ -753,128 +743,10 @@ textarea {
align-content: start;
}
.daily-education-panel,
.daily-quiz,
.quiz-options,
.education-question-editor {
display: grid;
gap: 1rem;
}
.daily-education-panel.condensed {
gap: 0.35rem;
padding-block: 1rem;
}
.daily-education-panel.condensed .panel-header {
margin-bottom: 0;
}
.daily-education-teaser {
margin: 0;
}
.daily-fact {
margin: 0;
padding: 1rem;
border-left: 4px solid var(--accent-gold);
border-radius: 0 8px 8px 0;
background: rgba(255, 254, 250, 0.7);
font-size: 1.08rem;
}
.daily-quiz {
grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr));
}
.quiz-question,
.quiz-editor-question {
min-width: 0;
margin: 0;
border: 1px solid var(--button-border);
}
.quiz-question {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: 8px;
background: rgba(255, 254, 250, 0.64);
}
.quiz-question legend,
.quiz-editor-question legend {
padding: 0 0.35rem;
font-weight: 700;
}
.quiz-option {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 0.65rem;
min-width: 0;
padding: 0.7rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.58);
}
.quiz-option.correct {
border-color: rgba(35, 138, 90, 0.42);
background: rgba(223, 247, 229, 0.82);
}
.quiz-option.incorrect {
border-color: rgba(203, 58, 53, 0.36);
background: rgba(255, 236, 232, 0.82);
}
.quiz-option input {
width: auto;
margin: 0.25rem 0 0;
}
.quiz-feedback {
margin: 0;
color: var(--accent-red);
}
.quiz-feedback.correct {
color: var(--accent-green);
}
.admin-education-panel,
.education-admin-basics,
.quiz-editor-question,
.education-admin-list {
display: grid;
gap: 1rem;
}
.education-admin-basics {
grid-template-columns: minmax(180px, 0.35fr) minmax(0, 1fr);
}
.quiz-editor-question {
padding: 1rem;
}
.quiz-editor-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.education-admin-list span {
overflow-wrap: anywhere;
}
.button-row {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
min-width: 0;
}
.member-header-actions {
@@ -975,7 +847,6 @@ textarea {
.bird-list {
display: grid;
gap: 0.9rem;
min-width: 0;
}
.bird-card-header {
@@ -1235,11 +1106,6 @@ textarea {
var(--card-bg);
position: relative;
overflow: hidden;
min-width: 0;
}
.chart-card .weight-chart {
display: block;
}
.overview-chart-card::before {
@@ -1724,7 +1590,6 @@ textarea {
.bird-detail-tab-panel {
display: grid;
gap: 1rem;
min-width: 0;
}
.profile-copy {
@@ -2092,7 +1957,6 @@ label {
.bulk-weight-table-shell {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: 22px;
border: 1px solid rgba(53, 129, 98, 0.18);
background: rgba(255, 252, 246, 0.72);
@@ -2563,11 +2427,6 @@ label {
}
@media (max-width: 980px) {
.education-admin-basics,
.quiz-editor-options {
grid-template-columns: 1fr;
}
.app-shell,
.auth-panel,
.hero-card,
@@ -2706,451 +2565,3 @@ label {
display: none;
}
}
@media (max-width: 720px) {
:root {
background:
radial-gradient(circle at 18% 8%, rgba(222, 124, 58, 0.18), transparent 24%),
radial-gradient(circle at 82% 10%, rgba(53, 136, 110, 0.18), transparent 24%),
linear-gradient(180deg, #fef5e7 0%, #ece3c8 48%, #dceee3 100%);
}
body::before {
opacity: 0.22;
background-size: auto 1200px;
}
input,
textarea,
select {
min-height: 46px;
border-radius: 14px;
padding: 0.78rem 0.85rem;
font-size: 16px;
}
.app-shell,
.auth-shell {
width: 100%;
padding: 0.75rem;
}
.app-shell {
gap: 0.75rem;
}
.content-shell,
.stack-grid,
.dashboard-grid,
.forms-grid,
.settings-grid,
.settings-column,
.flock-detail-column,
.flock-member-panel,
.flock-member-sections {
gap: 0.85rem;
}
.hero-card,
.panel {
border-radius: 20px;
padding: 1rem;
box-shadow: 0 14px 26px rgba(89, 48, 42, 0.12);
}
.hero-card {
gap: 1rem;
}
.hero-card h1 {
max-width: none;
font-size: 2.15rem;
line-height: 1.04;
}
.panel-header {
display: grid;
gap: 0.85rem;
margin-bottom: 0.85rem;
}
.panel-header .button-row,
.member-header-actions,
.overview-alert-actions {
justify-content: start;
}
.primary-button,
.secondary-button,
.danger-button,
.range-alert-button {
min-height: 44px;
border-radius: 14px;
padding: 0.72rem 0.9rem;
}
.button-row > button,
.button-row > a,
.top-alert-actions > button {
flex: 1 1 9rem;
}
.side-rail {
position: sticky;
top: 0;
z-index: 25;
margin: -0.75rem -0.75rem 0;
padding: 0.6rem 0.75rem 0.7rem;
grid-template-columns: 1fr;
gap: 0.55rem;
background: linear-gradient(180deg, rgba(254, 245, 231, 0.98), rgba(254, 245, 231, 0.88));
border-bottom: 1px solid rgba(53, 129, 98, 0.16);
backdrop-filter: blur(14px);
}
.brand-lockup {
display: none;
}
.side-nav.panel {
grid-template-columns: minmax(0, 1fr) auto;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.page-tabs {
margin: 0 -0.2rem;
padding: 0.1rem 0.2rem 0.25rem;
grid-auto-columns: minmax(5.4rem, 1fr);
gap: 0.35rem;
scroll-snap-type: x proximity;
}
.page-tab {
min-height: 40px;
min-width: 5.4rem;
padding: 0.5rem 0.55rem;
border-radius: 999px;
font-size: 0.86rem;
scroll-snap-align: start;
}
.side-nav .secondary-button {
min-height: 40px;
padding: 0.5rem 0.7rem;
border-radius: 999px;
font-size: 0.86rem;
}
.workspace-switcher {
gap: 0.4rem;
}
.workspace-switcher-list {
margin: 0 -0.2rem;
padding: 0 0.2rem 0.2rem;
}
.workspace-switcher-item {
min-width: min(180px, 78vw);
padding: 0.5rem 0.65rem;
border-radius: 999px;
font-size: 0.86rem;
}
.top-alert-notification {
gap: 0.65rem;
padding: 0.75rem;
border-radius: 16px;
}
.notification-bell {
width: 30px;
height: 30px;
}
.chart-card {
padding: 0.75rem;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.chart-card .weight-chart {
min-width: 520px;
}
.overview-chart-card {
min-height: 230px;
}
.chart-footer {
grid-template-columns: 1fr;
}
.legend-grid,
.detail-grid,
.summary-grid {
grid-template-columns: repeat(auto-fit, minmax(min(150px, 100%), 1fr));
}
.bird-list-panel {
order: 1;
}
.flock-detail-column {
order: 2;
}
.bird-list {
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
}
.bird-card {
border-radius: 18px;
padding: 0.8rem;
}
.bird-avatar {
width: 50px;
height: 50px;
border-radius: 16px;
}
.bird-detail-panel {
margin-right: 0;
}
.bird-detail-tabs {
position: sticky;
top: 4.35rem;
z-index: 15;
margin: -0.2rem -0.35rem 0.65rem;
padding: 0.2rem 0.35rem 0.35rem;
display: flex;
flex-direction: row;
overflow-x: auto;
gap: 0.35rem;
background: linear-gradient(180deg, rgba(250, 244, 232, 0.96), rgba(250, 244, 232, 0.84));
border-radius: 16px;
backdrop-filter: blur(12px);
transform: none;
scrollbar-width: thin;
}
.bird-detail-tab {
width: 42px;
height: 40px;
min-width: 42px;
border-left: 1px solid rgba(39, 105, 179, 0.14);
border-radius: 14px;
}
.bird-detail-tab.active {
box-shadow: 0 8px 16px rgba(39, 105, 179, 0.12);
}
.profile-hero {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
padding: 0.85rem;
gap: 0.75rem;
}
.profile-actions {
position: static;
grid-column: 1 / -1;
justify-content: start;
}
.profile-photo {
width: 86px;
height: 86px;
border-radius: 22px;
}
.profile-copy h3 {
font-size: 1.35rem;
overflow-wrap: anywhere;
}
.inline-form,
.settings-nested-grid,
.education-admin-basics,
.quiz-editor-options,
.dose-schedule-row,
.verified-location-search-row,
.verified-location-search-row.has-selected-location,
.medication-dose-row {
grid-template-columns: 1fr;
}
.verified-location-search-row button,
.verified-location-search-row.has-selected-location button {
width: 100%;
}
.toggle-field {
padding-top: 0;
}
.checkbox-row {
width: 100%;
padding: 0.75rem;
border-radius: 14px;
}
.segmented-control {
grid-template-columns: repeat(auto-fit, minmax(min(8rem, 100%), 1fr));
}
.segmented-option {
border-radius: 14px;
padding: 0.72rem 0.8rem;
}
.photo-editor {
grid-template-columns: 1fr;
justify-items: start;
padding: 0.85rem;
}
.color-preview-card {
align-items: start;
border-radius: 16px;
padding: 0.8rem;
}
.bulk-weight-table {
min-width: 560px;
}
.bulk-weight-table th,
.bulk-weight-table td {
padding: 0.75rem;
}
.app-modal-backdrop {
padding: 0.75rem;
align-items: end;
}
.app-modal {
max-height: min(88vh, 760px);
border-radius: 22px;
padding: 1rem;
}
}
@media (max-width: 430px) {
.app-shell,
.auth-shell {
padding: 0.55rem;
}
.side-rail {
margin: -0.55rem -0.55rem 0;
padding: 0.5rem 0.55rem 0.6rem;
}
.content-shell,
.stack-grid,
.dashboard-grid,
.forms-grid,
.settings-grid,
.settings-column,
.flock-detail-column,
.flock-member-panel,
.flock-member-sections {
gap: 0.7rem;
}
.hero-card,
.panel {
border-radius: 18px;
padding: 0.85rem;
}
.hero-card h1 {
font-size: 1.9rem;
}
.page-tabs {
grid-auto-columns: minmax(4.8rem, 1fr);
}
.page-tab {
min-width: 4.8rem;
font-size: 0.8rem;
}
.side-nav .secondary-button {
padding-inline: 0.58rem;
font-size: 0.8rem;
}
.top-alert-notification {
grid-template-columns: 1fr;
}
.notification-bell {
display: none;
}
.button-row > button,
.button-row > a,
.top-alert-actions > button,
.settings-save-row > button {
flex: 1 1 100%;
width: 100%;
}
.bird-card-header {
gap: 0.65rem;
}
.bird-card-title {
align-items: flex-start;
flex-wrap: wrap;
}
.profile-hero {
grid-template-columns: 1fr;
}
.profile-photo {
width: 96px;
height: 96px;
}
.profile-actions {
width: 100%;
}
.profile-icon-button {
flex: 1 1 42px;
}
.chart-card {
margin-inline: -0.1rem;
}
.settings-nested-card,
.inset-panel,
.legend-card,
.detail-card,
.summary-card,
.weight-reference-card,
.vet-visit-card,
.toggle-card {
border-radius: 16px;
padding: 0.8rem;
}
.app-modal-backdrop {
padding: 0.5rem;
}
}