Compare commits
42
Commits
dev
..
3ba6410972
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ba6410972 | ||
|
|
4a43a450f3 | ||
|
|
46605d8717 | ||
|
|
8f1144de1a | ||
|
|
53b75588a2 | ||
|
|
1849ecd73b | ||
|
|
53b7d34520 | ||
|
|
f65a4bed24 | ||
|
|
cc4a2382c6 | ||
|
|
5735bb7735 | ||
|
|
88ff06237e | ||
|
|
fbb13561b0 | ||
|
|
b15861c856 | ||
|
|
2aeaa119f7 | ||
|
|
36690c0174 | ||
|
|
b76ad35c07 | ||
|
|
6918b55a58 | ||
|
|
49f1713e26 | ||
|
|
c9fa7e4246 | ||
|
|
0411ec5175 | ||
|
|
7b7171c109 | ||
|
|
c02bb4d6d8 | ||
|
|
603b4eee4d | ||
|
|
52008f5b43 | ||
|
|
5b57cdd6bf | ||
|
|
60eadf0847 | ||
|
|
682ccfd41f | ||
|
|
59c6b19ad6 | ||
|
|
aa1a4cf6ff | ||
|
|
5f0fad3cbb | ||
|
|
545fae59b2 | ||
|
|
d748d2db21 | ||
|
|
095c91e56d | ||
|
|
f2017068d5 | ||
|
|
c9702495a3 | ||
|
|
e965cb55ef | ||
|
|
505a9b8496 | ||
|
|
c6dc5b22b8 | ||
|
|
f16e88e2f0 | ||
|
|
016bc187d4 | ||
|
|
104f01f75d | ||
|
|
568aee3e70 |
@@ -39,10 +39,3 @@ STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY=
|
|||||||
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
|
STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success
|
||||||
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
|
STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled
|
||||||
STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal
|
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
@@ -3,13 +3,14 @@ name: Deploy
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
|
- main
|
||||||
- dev
|
- dev
|
||||||
- develop
|
- develop
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy-dev:
|
deploy-dev:
|
||||||
if: ${{ github.event_name == 'push' }}
|
if: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'develop') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
volumes:
|
volumes:
|
||||||
@@ -47,69 +48,8 @@ jobs:
|
|||||||
cd /docker/FlockPal-dev
|
cd /docker/FlockPal-dev
|
||||||
docker compose -f docker-compose.dev.yaml up -d --build
|
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:
|
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
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
volumes:
|
volumes:
|
||||||
@@ -146,64 +86,3 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
cd /docker/FlockPal
|
cd /docker/FlockPal
|
||||||
docker compose -f docker-compose.prod.yml up -d --build
|
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."
|
|
||||||
|
|||||||
@@ -47,6 +47,23 @@ The default `docker-compose.yml` is development-only. It mounts source files, in
|
|||||||
|
|
||||||
## Operations
|
## 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
|
### Backups
|
||||||
|
|
||||||
Create a compressed Postgres backup from the Docker Compose Postgres service:
|
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_ACCESS_KEY_ID`
|
||||||
- `S3_SECRET_ACCESS_KEY`
|
- `S3_SECRET_ACCESS_KEY`
|
||||||
- `RESCUE_ONBOARDING_WEBHOOK_URL`
|
- `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:
|
2. Build and start the production stack:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -161,34 +176,6 @@ npm run build
|
|||||||
npm run worker
|
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
|
## Auth and flock notes
|
||||||
|
|
||||||
- One user can belong to multiple flocks.
|
- 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
@@ -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 crypto from 'crypto';
|
||||||
import { existsSync } from 'fs';
|
import { existsSync } from 'fs';
|
||||||
import path from 'path';
|
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 rateLimit from 'express-rate-limit';
|
||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import morgan from 'morgan';
|
import morgan from 'morgan';
|
||||||
import nodemailer from 'nodemailer';
|
import nodemailer, { type SendMailOptions } from 'nodemailer';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -81,19 +78,6 @@ import {
|
|||||||
listAuditLogEntries,
|
listAuditLogEntries,
|
||||||
listFlockNotes,
|
listFlockNotes,
|
||||||
} from './repositories/auditRepository.js';
|
} from './repositories/auditRepository.js';
|
||||||
import {
|
|
||||||
deleteDailyEducation,
|
|
||||||
deleteEducationQuestion,
|
|
||||||
createEducationQuestion,
|
|
||||||
getDailyEducationForDate,
|
|
||||||
getEducationOptOut,
|
|
||||||
listDailyEducationForAdmin,
|
|
||||||
listDailyEducationQuestions,
|
|
||||||
listEducationQuestionsForAdmin,
|
|
||||||
updateEducationOptOut,
|
|
||||||
updateEducationQuestion,
|
|
||||||
upsertDailyEducation,
|
|
||||||
} from './repositories/educationRepository.js';
|
|
||||||
import {
|
import {
|
||||||
buildBirdPhotoObjectKey,
|
buildBirdPhotoObjectKey,
|
||||||
getImageExtensionFromContentType,
|
getImageExtensionFromContentType,
|
||||||
@@ -133,8 +117,6 @@ import type {
|
|||||||
AuditLogEntryRow,
|
AuditLogEntryRow,
|
||||||
BillingInterval,
|
BillingInterval,
|
||||||
BillingPlan,
|
BillingPlan,
|
||||||
DailyEducationRow,
|
|
||||||
EducationQuestionRow,
|
|
||||||
BirdGender,
|
BirdGender,
|
||||||
BirdMilestoneReminderCandidateRow,
|
BirdMilestoneReminderCandidateRow,
|
||||||
BirdRow,
|
BirdRow,
|
||||||
@@ -540,27 +522,6 @@ const integrationTokenCreateSchema = z.object({
|
|||||||
expiresInDays: z.coerce.number().int().min(1).max(3650).optional(),
|
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 emptyToNull = (value?: string) => {
|
||||||
const trimmed = value?.trim() ?? '';
|
const trimmed = value?.trim() ?? '';
|
||||||
return trimmed ? trimmed : null;
|
return trimmed ? trimmed : null;
|
||||||
@@ -755,25 +716,6 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
|
|||||||
createdAt: row.created_at,
|
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) => {
|
const signBirdPhotoAccessToken = (row: BirdRow) => {
|
||||||
if (!row.photo_object_key) {
|
if (!row.photo_object_key) {
|
||||||
return '';
|
return '';
|
||||||
@@ -1397,14 +1339,12 @@ const sendMagicLink = async ({
|
|||||||
'',
|
'',
|
||||||
'This link expires in 15 minutes and can only be used once.',
|
'This link expires in 15 minutes and can only be used once.',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
...await buildEmailLayout({ eyebrow: 'Sign in', headline: 'Your FlockPal sign-in link',
|
html: `
|
||||||
contentHtml: `
|
<p>Hi ${name || 'there'},</p>
|
||||||
<p>Hi ${escapeHtml(name || 'there')},</p>
|
|
||||||
<p>Use this secure link to sign in to FlockPal:</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>
|
<p>This link expires in 15 minutes and can only be used once.</p>
|
||||||
`,
|
`,
|
||||||
action: { url: magicLinkUrl, label: 'Sign in to FlockPal' },
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1475,6 +1415,27 @@ const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => {
|
|||||||
return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0;
|
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 parseDataImage = (dataUrl: string) => {
|
||||||
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
|
const match = /^data:(image\/[a-zA-Z0-9.+-]+);base64,(.+)$/.exec(dataUrl);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
@@ -1619,6 +1580,22 @@ const loadBirdReportPhotoBuffer = async (bird: BirdRow) => {
|
|||||||
return Buffer.from(await imageResponse.arrayBuffer());
|
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 ({
|
const sendRescueStatusNotification = async ({
|
||||||
workspace,
|
workspace,
|
||||||
ownerEmail,
|
ownerEmail,
|
||||||
@@ -1668,8 +1645,7 @@ const sendRescueStatusNotification = async ({
|
|||||||
to: rescueStatusNotificationEmail,
|
to: rescueStatusNotificationEmail,
|
||||||
subject,
|
subject,
|
||||||
text: lines.join('\n'),
|
text: lines.join('\n'),
|
||||||
...await buildEmailLayout({ eyebrow: 'Rescue status', headline: 'Rescue flock update',
|
html: `
|
||||||
contentHtml: `
|
|
||||||
<p>A rescue flock was ${eventLabel}.</p>
|
<p>A rescue flock was ${eventLabel}.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Rescue flock:</strong> ${escapedWorkspaceName}</li>
|
<li><strong>Rescue flock:</strong> ${escapedWorkspaceName}</li>
|
||||||
@@ -1679,9 +1655,7 @@ const sendRescueStatusNotification = async ({
|
|||||||
<li><strong>Flock ID:</strong> ${workspace.id}</li>
|
<li><strong>Flock ID:</strong> ${workspace.id}</li>
|
||||||
</ul>
|
</ul>
|
||||||
${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''}
|
${escapedNote ? `<p><strong>Note:</strong> ${escapedNote}</p>` : ''}
|
||||||
`,
|
`,
|
||||||
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return { delivered: true };
|
return { delivered: true };
|
||||||
@@ -1820,15 +1794,13 @@ const issueBirdTransferInvite = async ({
|
|||||||
to: email,
|
to: email,
|
||||||
subject,
|
subject,
|
||||||
text,
|
text,
|
||||||
...await buildEmailLayout({ eyebrow: 'Bird transfer', headline: 'A bird is joining your flock',
|
html: `
|
||||||
contentHtml: `
|
|
||||||
<p>Hi there,</p>
|
<p>Hi there,</p>
|
||||||
<p><strong>${escapeHtml(sourceWorkspaceName)}</strong> wants to transfer <strong>${escapeHtml(birdName)}</strong> to your FlockPal account.</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>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>
|
<p>This link expires in 15 minutes and can only be used once.</p>
|
||||||
`,
|
`,
|
||||||
action: { url: magicLinkUrl, label: 'Accept bird transfer' },
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1884,8 +1856,7 @@ const sendLostBirdReportNotification = async ({
|
|||||||
replyTo: emptyToNull(report.finderEmail) ?? undefined,
|
replyTo: emptyToNull(report.finderEmail) ?? undefined,
|
||||||
subject,
|
subject,
|
||||||
text: lines.join('\n'),
|
text: lines.join('\n'),
|
||||||
...await buildEmailLayout({ eyebrow: 'Found bird report', headline: 'A possible match for your bird',
|
html: `
|
||||||
contentHtml: `
|
|
||||||
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
|
<p>A possible found bird report was submitted for <strong>${escapeHtml(bird.name)}</strong>.</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><strong>Band ID:</strong> ${escapeHtml(bird.tag_id ?? 'Not recorded')}</li>
|
<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>
|
<li><strong>Message:</strong> ${escapeHtml(message)}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p>FlockPal does not verify found bird reports. Please use care before sharing personal information or arranging a pickup.</p>
|
<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 };
|
return { delivered: true };
|
||||||
@@ -1975,7 +1944,7 @@ const buildMedicationReminderCopy = (reminder: MedicationReminderCandidateRow) =
|
|||||||
return {
|
return {
|
||||||
subject: `${reminder.medication_name} reminder for ${reminder.name}`,
|
subject: `${reminder.medication_name} reminder for ${reminder.name}`,
|
||||||
eyebrow: 'Medication Reminder',
|
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}.`,
|
intro: `${reminder.name} is due for ${reminder.medication_name} at ${doseTime}.`,
|
||||||
body: `Dose: ${reminder.dosage}${route}.`,
|
body: `Dose: ${reminder.dosage}${route}.`,
|
||||||
detailLabel: `${slotLabel} at ${doseTime}`,
|
detailLabel: `${slotLabel} at ${doseTime}`,
|
||||||
@@ -1996,6 +1965,32 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copy = buildBirdMilestoneReminderCopy(reminder);
|
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 = [
|
const lines = [
|
||||||
copy.headline,
|
copy.headline,
|
||||||
'',
|
'',
|
||||||
@@ -2020,18 +2015,43 @@ const sendBirdMilestoneReminderNotification = async ({
|
|||||||
bcc: uniqueRecipients,
|
bcc: uniqueRecipients,
|
||||||
subject: copy.subject,
|
subject: copy.subject,
|
||||||
text: lines.join('\n'),
|
text: lines.join('\n'),
|
||||||
...await buildEmailLayout({
|
attachments,
|
||||||
bird: reminder,
|
html: `
|
||||||
eyebrow: copy.eyebrow,
|
<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;">
|
||||||
headline: copy.headline,
|
<div style="max-width: 680px; margin: 0 auto 18px;">
|
||||||
preheader: copy.intro,
|
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||||
contentHtml: `<p style="margin:0 0 16px;font-size:16px;">${escapeHtml(copy.intro)}</p>
|
</div>
|
||||||
<p>${escapeHtml(copy.body)}</p>
|
<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);">
|
||||||
<p><strong>${escapeHtml(reminder.name)}</strong> · ${escapeHtml(reminder.species)}</p>
|
<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);">
|
||||||
<p>${escapeHtml(`${copy.eventName}: ${copy.milestoneLabel}`)}</p>
|
${
|
||||||
`,
|
logoAttachment
|
||||||
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
|
? '<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 };
|
return { delivered: true };
|
||||||
@@ -2051,6 +2071,35 @@ const sendMedicationReminderNotification = async ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const copy = buildMedicationReminderCopy(reminder);
|
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 = [
|
const lines = [
|
||||||
copy.headline,
|
copy.headline,
|
||||||
'',
|
'',
|
||||||
@@ -2077,18 +2126,46 @@ const sendMedicationReminderNotification = async ({
|
|||||||
bcc: uniqueRecipients,
|
bcc: uniqueRecipients,
|
||||||
subject: copy.subject,
|
subject: copy.subject,
|
||||||
text: lines.join('\n'),
|
text: lines.join('\n'),
|
||||||
...await buildEmailLayout({
|
attachments,
|
||||||
bird: reminder,
|
html: `
|
||||||
eyebrow: copy.eyebrow,
|
<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;">
|
||||||
headline: copy.headline,
|
<div style="max-width: 680px; margin: 0 auto 18px;">
|
||||||
preheader: copy.intro,
|
<img src="${trackPatternDataUrl}" alt="" style="display: block; width: 100%; max-width: 680px; height: auto; border-radius: 26px;" />
|
||||||
contentHtml: `<p style="margin:0 0 16px;font-size:16px;">${escapeHtml(copy.intro)}</p>
|
</div>
|
||||||
<p>${escapeHtml(copy.body)}</p>
|
<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);">
|
||||||
<p><strong>${escapeHtml(reminder.name)}</strong> · ${escapeHtml(reminder.species)}</p>
|
<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);">
|
||||||
<p>${escapeHtml(copy.detailLabel)}</p>
|
${
|
||||||
${reminder.medication_notes ? `<p><strong>Medication notes:</strong> ${escapeHtml(reminder.medication_notes)}</p>` : ''}`,
|
logoAttachment
|
||||||
action: { url: frontendBaseUrl, label: 'Open FlockPal' },
|
? '<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 };
|
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()) => {
|
export const runMedicationReminders = async (runDate = getDateInTimeZone(), currentTime = getTimeInTimeZone()) => {
|
||||||
const reminders = await listDueMedicationReminders(runDate, currentTime);
|
const reminders = await listDueMedicationReminders(runDate, currentTime);
|
||||||
let sent = 0;
|
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) => {
|
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);
|
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
|
||||||
|
|
||||||
|
|||||||
@@ -30,9 +30,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
|
|||||||
ALTER TABLE workspaces
|
ALTER TABLE workspaces
|
||||||
DROP CONSTRAINT IF EXISTS workspaces_id_check;
|
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
|
ALTER TABLE workspaces
|
||||||
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
|
||||||
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
|
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
|
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
|
||||||
ON auth_sessions (created_at DESC, user_id);
|
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 (
|
CREATE TABLE IF NOT EXISTS integration_tokens (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
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)
|
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 (
|
CREATE TABLE IF NOT EXISTS vet_visits (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -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')));
|
|
||||||
};
|
|
||||||
|
|
||||||
@@ -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 <reminder>/);
|
|
||||||
assert.match(html, /Medication time for Kiwi & Peep/);
|
|
||||||
assert.match(html, /a=1&b=2/);
|
|
||||||
assert.match(html, /Open <FlockPal>/);
|
|
||||||
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/);
|
|
||||||
});
|
|
||||||
@@ -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 => ({
|
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
||||||
})[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 & <Peep>/);
|
|
||||||
assert.match(String(mail.html), /Our <Flock>/);
|
|
||||||
assert.match(String(mail.html), /a=1&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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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()]);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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,
|
createBird,
|
||||||
createPendingBirdTransfer,
|
createPendingBirdTransfer,
|
||||||
getBirdById,
|
getBirdById,
|
||||||
|
getOpenBirdTransferCode,
|
||||||
listWeightsForBird,
|
listWeightsForBird,
|
||||||
|
markBirdTransferCodeCompleted,
|
||||||
transferBirdToWorkspace,
|
transferBirdToWorkspace,
|
||||||
} from './birdRepository.js';
|
} from './birdRepository.js';
|
||||||
import { mockDb } from '../test/mockDb.js';
|
import { mockDb } from '../test/mockDb.js';
|
||||||
@@ -253,3 +255,25 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
|||||||
null,
|
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);
|
|
||||||
};
|
|
||||||
@@ -13,38 +13,9 @@ export type UserRow = {
|
|||||||
email: string;
|
email: string;
|
||||||
password_hash: string | null;
|
password_hash: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
education_opt_out?: boolean;
|
|
||||||
created_at: string;
|
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 = {
|
export type WorkspaceRow = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { runWeightReminders } from './reminders/weightReminders.js';
|
|
||||||
import { weightReminderQueue, weightReminderQueueName, startWeightReminderScheduler } from './queues/weightReminderQueue.js';
|
|
||||||
import { Worker } from 'bullmq';
|
import { Worker } from 'bullmq';
|
||||||
|
|
||||||
import { ensureSchema } from './db/schema.js';
|
import { ensureSchema } from './db/schema.js';
|
||||||
import { db } from './db/client.js';
|
import { db } from './db/client.js';
|
||||||
import {
|
import {
|
||||||
sendWeightReminderNotification,
|
|
||||||
runBirdMilestoneReminders,
|
runBirdMilestoneReminders,
|
||||||
runMedicationReminders,
|
runMedicationReminders,
|
||||||
startBirdMilestoneReminderScheduler,
|
startBirdMilestoneReminderScheduler,
|
||||||
@@ -32,8 +29,6 @@ import {
|
|||||||
import { redisConnection } from './queues/redisConnection.js';
|
import { redisConnection } from './queues/redisConnection.js';
|
||||||
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
import { renderAdoptionReportForBird } from './reports/adoptionReportJob.js';
|
||||||
|
|
||||||
let stopWeightReminderScheduler: (() => void) | undefined;
|
|
||||||
let weightReminderWorker: Worker | null = null;
|
|
||||||
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
let birdMilestoneWorker: Worker<BirdMilestoneReminderJobData, BirdMilestoneReminderJobResult> | null = null;
|
||||||
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
|
let medicationReminderWorker: Worker<MedicationReminderJobData, MedicationReminderJobResult> | null = null;
|
||||||
let adoptionReportWorker: Worker<AdoptionReportJobData, AdoptionReportJobResult> | 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);
|
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();
|
startBirdMilestoneReminderScheduler();
|
||||||
startMedicationReminderScheduler();
|
startMedicationReminderScheduler();
|
||||||
console.log('FlockPal worker started.');
|
console.log('FlockPal worker started.');
|
||||||
@@ -115,9 +100,6 @@ const startWorker = async () => {
|
|||||||
|
|
||||||
const shutdown = async (signal: string) => {
|
const shutdown = async (signal: string) => {
|
||||||
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
console.log(`FlockPal worker received ${signal}; shutting down.`);
|
||||||
stopWeightReminderScheduler?.();
|
|
||||||
await weightReminderWorker?.close();
|
|
||||||
await weightReminderQueue.close();
|
|
||||||
await birdMilestoneWorker?.close();
|
await birdMilestoneWorker?.close();
|
||||||
await medicationReminderWorker?.close();
|
await medicationReminderWorker?.close();
|
||||||
await adoptionReportWorker?.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 & <Peep>'));
|
|
||||||
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
@@ -60,7 +60,6 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
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}
|
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}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
|
|
||||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
@@ -98,6 +97,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "dist/healthcheck.js", "api-ready"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
labels:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik
|
- traefik.docker.network=traefik
|
||||||
@@ -143,7 +148,6 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
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}
|
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}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
|
|
||||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
@@ -158,6 +162,12 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "dist/healthcheck.js", "worker"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
@@ -169,6 +179,12 @@ services:
|
|||||||
container_name: flockpal-frontend
|
container_name: flockpal-frontend
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- 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:
|
labels:
|
||||||
- traefik.enable=true
|
- traefik.enable=true
|
||||||
- traefik.docker.network=traefik
|
- traefik.docker.network=traefik
|
||||||
|
|||||||
@@ -58,7 +58,6 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
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}
|
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}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
|
|
||||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
|
||||||
@@ -136,7 +135,6 @@ services:
|
|||||||
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
|
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}
|
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}
|
MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
|
||||||
WEIGHT_REMINDERS_ENABLED: ${WEIGHT_REMINDERS_ENABLED:-true}
|
|
||||||
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
MEDICATION_REMINDERS_ENABLED: ${MEDICATION_REMINDERS_ENABLED:-true}
|
||||||
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
|
||||||
SMTP_HOST: ${SMTP_HOST:-}
|
SMTP_HOST: ${SMTP_HOST:-}
|
||||||
|
|||||||
+35
-2
@@ -319,14 +319,47 @@ Validation failures return `400` with this shape:
|
|||||||
|
|
||||||
#### `GET /api/health`
|
#### `GET /api/health`
|
||||||
|
|
||||||
Public health check.
|
Public readiness-compatible health check. Verifies backend dependencies.
|
||||||
|
|
||||||
Response `200`:
|
Response `200`:
|
||||||
|
|
||||||
```json
|
```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
|
### Metrics
|
||||||
|
|
||||||
#### `GET /api/metrics`
|
#### `GET /api/metrics`
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -12,6 +12,12 @@ server {
|
|||||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" 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 / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 237 KiB |
+2
-567
@@ -193,23 +193,6 @@ type AdminRescueWorkspace = {
|
|||||||
memberCount: number;
|
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 = {
|
type IntegrationTokenSummary = {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -409,18 +392,6 @@ type BillingNotice = {
|
|||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DailyEducationQuestionFormState = {
|
|
||||||
prompt: string;
|
|
||||||
options: [string, string, string, string];
|
|
||||||
correctAnswerIndex: number;
|
|
||||||
explanation: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type DailyEducationFormState = {
|
|
||||||
publishDate: string;
|
|
||||||
fact: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BulkWeightRowState = {
|
type BulkWeightRowState = {
|
||||||
weightGrams: string;
|
weightGrams: string;
|
||||||
};
|
};
|
||||||
@@ -943,18 +914,6 @@ const emptyFlockNoteForm: FlockNoteFormState = {
|
|||||||
body: '',
|
body: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyDailyEducationQuestion = (): DailyEducationQuestionFormState => ({
|
|
||||||
prompt: '',
|
|
||||||
options: ['', '', '', ''],
|
|
||||||
correctAnswerIndex: 0,
|
|
||||||
explanation: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const emptyDailyEducationForm = (): DailyEducationFormState => ({
|
|
||||||
publishDate: new Date().toISOString().slice(0, 10),
|
|
||||||
fact: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const defaultAuthProviders: AuthProvider[] = [
|
const defaultAuthProviders: AuthProvider[] = [
|
||||||
{ providerKey: 'google', displayName: 'Google', enabled: false },
|
{ providerKey: 'google', displayName: 'Google', enabled: false },
|
||||||
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
|
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
|
||||||
@@ -2173,20 +2132,6 @@ function App() {
|
|||||||
const [birdTimelineEventForm, setBirdTimelineEventForm] = useState<BirdTimelineEventFormState>(emptyBirdTimelineEventForm);
|
const [birdTimelineEventForm, setBirdTimelineEventForm] = useState<BirdTimelineEventFormState>(emptyBirdTimelineEventForm);
|
||||||
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
||||||
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
|
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 [birds, setBirds] = useState<Bird[]>([]);
|
||||||
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
|
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
|
||||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||||
@@ -2797,15 +2742,6 @@ function App() {
|
|||||||
setAuditLogEntries([]);
|
setAuditLogEntries([]);
|
||||||
setAdminSummary(null);
|
setAdminSummary(null);
|
||||||
setAdminRescueWorkspaces([]);
|
setAdminRescueWorkspaces([]);
|
||||||
setAdminDailyEducation([]);
|
|
||||||
setAdminEducationQuestions([]);
|
|
||||||
setDailyEducationForm(emptyDailyEducationForm());
|
|
||||||
setEducationQuestionForm(emptyDailyEducationQuestion());
|
|
||||||
setEditingEducationQuestionId('');
|
|
||||||
setTodayEducation(null);
|
|
||||||
setEducationOptOut(false);
|
|
||||||
setEducationAnswers({});
|
|
||||||
setDailyEducationOpen(false);
|
|
||||||
setBirds([]);
|
setBirds([]);
|
||||||
setMemorializedBirds([]);
|
setMemorializedBirds([]);
|
||||||
setWeights([]);
|
setWeights([]);
|
||||||
@@ -3105,27 +3041,20 @@ function App() {
|
|||||||
|
|
||||||
const loadAdminDashboard = async () => {
|
const loadAdminDashboard = async () => {
|
||||||
try {
|
try {
|
||||||
const [summaryResponse, rescuesResponse, educationResponse, educationQuestionsResponse] = await Promise.all([
|
const [summaryResponse, rescuesResponse] = await Promise.all([
|
||||||
apiFetch('/admin/summary', authToken),
|
apiFetch('/admin/summary', authToken),
|
||||||
apiFetch('/admin/rescue-workspaces', 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.');
|
throw new Error('Unable to load admin dashboard.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
|
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
|
||||||
const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {};
|
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);
|
setAdminSummary(summaryData.summary ?? null);
|
||||||
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
|
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
|
||||||
setAdminDailyEducation(educationData.education ?? []);
|
|
||||||
setAdminEducationQuestions(educationQuestionsData.questions ?? []);
|
|
||||||
} catch (adminError) {
|
} catch (adminError) {
|
||||||
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
|
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
|
||||||
}
|
}
|
||||||
@@ -3134,34 +3063,6 @@ function App() {
|
|||||||
void loadAdminDashboard();
|
void loadAdminDashboard();
|
||||||
}, [activePage, authSession?.isAdmin, authToken]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!selectedBird?.id) {
|
if (!selectedBird?.id) {
|
||||||
setWeights([]);
|
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>) => {
|
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
@@ -6430,74 +6116,6 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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's fact and {todayEducation.quizQuestions.length || 'the'} quiz questions.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<section className="forms-grid">
|
<section className="forms-grid">
|
||||||
<article className="panel form-panel pulse-panel">
|
<article className="panel form-panel pulse-panel">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
@@ -6654,168 +6272,6 @@ function App() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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>
|
</section>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -8941,27 +8397,6 @@ function App() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="settings-column settings-column-right">
|
<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">
|
<article className="panel form-panel settings-card-collaborators">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ body,
|
|||||||
#root {
|
#root {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -49,8 +48,6 @@ body {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
position: relative;
|
position: relative;
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
overflow-x: hidden;
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body::before {
|
body::before {
|
||||||
@@ -124,7 +121,6 @@ textarea {
|
|||||||
.content-shell {
|
.content-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.top-alert-notification {
|
.top-alert-notification {
|
||||||
@@ -197,13 +193,11 @@ textarea {
|
|||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-nav {
|
.side-nav {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.25rem;
|
gap: 1.25rem;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-lockup {
|
.brand-lockup {
|
||||||
@@ -459,7 +453,6 @@ textarea {
|
|||||||
.stack-grid {
|
.stack-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-card,
|
.hero-card,
|
||||||
@@ -543,7 +536,6 @@ textarea {
|
|||||||
.forms-grid {
|
.forms-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-stats {
|
.hero-stats {
|
||||||
@@ -729,7 +721,6 @@ textarea {
|
|||||||
border-radius: 28px;
|
border-radius: 28px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-header {
|
.panel-header {
|
||||||
@@ -738,7 +729,6 @@ textarea {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pulse-panel .panel-header {
|
.pulse-panel .panel-header {
|
||||||
@@ -753,128 +743,10 @@ textarea {
|
|||||||
align-content: start;
|
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 {
|
.button-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.member-header-actions {
|
.member-header-actions {
|
||||||
@@ -975,7 +847,6 @@ textarea {
|
|||||||
.bird-list {
|
.bird-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.9rem;
|
gap: 0.9rem;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bird-card-header {
|
.bird-card-header {
|
||||||
@@ -1235,11 +1106,6 @@ textarea {
|
|||||||
var(--card-bg);
|
var(--card-bg);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chart-card .weight-chart {
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.overview-chart-card::before {
|
.overview-chart-card::before {
|
||||||
@@ -1724,7 +1590,6 @@ textarea {
|
|||||||
.bird-detail-tab-panel {
|
.bird-detail-tab-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-copy {
|
.profile-copy {
|
||||||
@@ -2092,7 +1957,6 @@ label {
|
|||||||
|
|
||||||
.bulk-weight-table-shell {
|
.bulk-weight-table-shell {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px solid rgba(53, 129, 98, 0.18);
|
border: 1px solid rgba(53, 129, 98, 0.18);
|
||||||
background: rgba(255, 252, 246, 0.72);
|
background: rgba(255, 252, 246, 0.72);
|
||||||
@@ -2563,11 +2427,6 @@ label {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
.education-admin-basics,
|
|
||||||
.quiz-editor-options {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-shell,
|
.app-shell,
|
||||||
.auth-panel,
|
.auth-panel,
|
||||||
.hero-card,
|
.hero-card,
|
||||||
@@ -2706,451 +2565,3 @@ label {
|
|||||||
display: none;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user