diff --git a/.env.example b/.env.example
index e7e779f..f32d6c1 100644
--- a/.env.example
+++ b/.env.example
@@ -8,6 +8,9 @@ NODE_ENV=development
TRUST_PROXY=
ADMIN_EMAILS=corey@blaishome.online
RESCUE_STATUS_NOTIFICATION_EMAIL=appadmin@flockpal.app
+DEFAULT_BIRD_PHOTO_PATH=/app/assets/yoda.png
+MILESTONE_REMINDERS_ENABLED=true
+MILESTONE_REMINDER_TIME_ZONE=America/New_York
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRICE_HOUSEHOLD_CONURE=
diff --git a/Yoda.png b/Yoda.png
new file mode 100644
index 0000000..00bc6c5
Binary files /dev/null and b/Yoda.png differ
diff --git a/backend/Dockerfile b/backend/Dockerfile
index 8f6b45e..4f5b8f2 100644
--- a/backend/Dockerfile
+++ b/backend/Dockerfile
@@ -12,5 +12,6 @@ ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
+COPY assets ./assets
EXPOSE 5000
CMD ["npm", "run", "start"]
diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev
index a3bc9f4..78958b9 100644
--- a/backend/Dockerfile.dev
+++ b/backend/Dockerfile.dev
@@ -4,5 +4,6 @@ COPY package*.json ./
RUN npm install --include=dev
COPY tsconfig.json ./
COPY src ./src
+COPY assets ./assets
EXPOSE 5000
CMD ["npm", "run", "dev"]
diff --git a/backend/assets/yoda.png b/backend/assets/yoda.png
new file mode 100644
index 0000000..00bc6c5
Binary files /dev/null and b/backend/assets/yoda.png differ
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 5d0fd04..4e609af 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -1,4 +1,6 @@
import crypto from 'crypto';
+import { readFileSync } from 'fs';
+import path from 'path';
import cors from 'cors';
import dotenv from 'dotenv';
import express, { type NextFunction, type Request, type Response } from 'express';
@@ -30,7 +32,7 @@ import {
import {
completePendingBirdTransfersForOwner,
createBird,
- upsertMedicationAdministrationForBird,
+ createBirdMilestoneReminderDelivery,
createMedicationForBird,
createPendingBirdTransfer,
findBirdsByBandId,
@@ -41,6 +43,7 @@ import {
deleteVetVisitForBird,
getBirdById,
listBirds,
+ listDueBirdMilestoneReminders,
listMedicationAdministrationsForBird,
listMedicationsForBird,
listVetVisitsForBird,
@@ -48,6 +51,7 @@ import {
transferBirdToWorkspace,
updateBird,
updateMedicationForBird,
+ upsertMedicationAdministrationForBird,
updateVetVisitForBird,
} from './repositories/birdRepository.js';
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js';
@@ -81,6 +85,7 @@ import type {
BillingInterval,
BillingPlan,
BirdGender,
+ BirdMilestoneReminderCandidateRow,
BirdRow,
IntegrationTokenRow,
LostBirdMatchRow,
@@ -114,6 +119,9 @@ const frontendBaseUrl = process.env.FRONTEND_URL ?? 'http://localhost:3000';
const backendBaseUrl = process.env.BACKEND_URL ?? `http://localhost:${port}`;
const sessionDays = 30;
const trustProxy = process.env.TRUST_PROXY?.trim() ?? '';
+const milestoneRemindersEnabled = (process.env.MILESTONE_REMINDERS_ENABLED ?? 'true').toLowerCase() !== 'false';
+const milestoneReminderTimeZone = process.env.MILESTONE_REMINDER_TIME_ZONE?.trim() || 'America/New_York';
+const milestoneReminderCheckIntervalMs = 60 * 60 * 1000;
if (trustProxy) {
app.set('trust proxy', trustProxy === 'true' ? true : Number(trustProxy) || trustProxy);
@@ -793,6 +801,64 @@ const escapeHtml = (value: string) =>
.replace(/"/g, '"')
.replace(/'/g, ''');
+const getDateInTimeZone = (date = new Date(), timeZone = milestoneReminderTimeZone) => {
+ const parts = new Intl.DateTimeFormat('en-US', {
+ timeZone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(date);
+ const year = parts.find((part) => part.type === 'year')?.value ?? `${date.getUTCFullYear()}`;
+ const month = parts.find((part) => part.type === 'month')?.value ?? `${date.getUTCMonth() + 1}`.padStart(2, '0');
+ const day = parts.find((part) => part.type === 'day')?.value ?? `${date.getUTCDate()}`.padStart(2, '0');
+ return `${year}-${month}-${day}`;
+};
+
+const formatOrdinal = (value: number) => {
+ const remainder = value % 100;
+ if (remainder >= 11 && remainder <= 13) {
+ return `${value}th`;
+ }
+
+ switch (value % 10) {
+ case 1:
+ return `${value}st`;
+ case 2:
+ return `${value}nd`;
+ case 3:
+ return `${value}rd`;
+ default:
+ return `${value}th`;
+ }
+};
+
+const getMilestoneYearCount = (reminder: BirdMilestoneReminderCandidateRow) => {
+ const sourceDate = reminder.reminder_type === 'hatch_day' ? reminder.date_of_birth : reminder.gotcha_day;
+ const sourceYear = Number(sourceDate?.slice(0, 4));
+ return Number.isFinite(sourceYear) ? Math.max(0, reminder.reminder_year - sourceYear) : 0;
+};
+
+const getFlockPalLogoDataUrl = () =>
+ 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22%3E%3Cdefs%3E%3ClinearGradient id=%22featherFill%22 x1=%220%25%22 y1=%220%25%22 x2=%22100%25%22 y2=%22100%25%22%3E%3Cstop offset=%220%25%22 stop-color=%22%23cb3a35%22/%3E%3Cstop offset=%2230%25%22 stop-color=%22%23f0b63f%22/%3E%3Cstop offset=%2258%25%22 stop-color=%22%23238a5a%22/%3E%3Cstop offset=%22100%25%22 stop-color=%22%232769b3%22/%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath d=%22M50.8 10.4C37.9 10.3 27 18.5 22.7 31.1c-3.1 9.1-2.1 18.5-8.6 24.8c-1.5 1.5-0.2 4 1.9 3.6c8.4-1.5 14.6-6.7 18.6-13.7c1 0.5 2.2 0.8 3.4 0.8c3.5 0 6.5-2.3 7.5-5.4c1.9-0.4 3.7-1.3 5.1-2.7c2-2 3-4.6 3.1-7.2c3.3-5.8 4.9-12.9 1.4-20.2c-0.7-1.3-2-0.7-4.3-0.7Z%22 fill=%22url(%23featherFill)%22/%3E%3Cpath d=%22M18 56c8.5-3.4 14.2-9.8 18.1-17.8M26.9 48.9c6.9-7.2 13.5-14.8 20.3-22.1M31.8 41.2c6.4-1.3 12.1-4.6 16.5-9.4M36.8 33.8c4.9-0.9 9.2-3.4 12.6-7.1%22 fill=%22none%22 stroke=%22%23fff8ef%22 stroke-linecap=%22round%22 stroke-width=%222.6%22/%3E%3Cpath d=%22M18 56c8.5-3.4 14.2-9.8 18.1-17.8%22 fill=%22none%22 stroke=%22%2363562d%22 stroke-linecap=%22round%22 stroke-width=%222.2%22/%3E%3C/svg%3E';
+
+let defaultBirdPhotoDataUrl: string | null = null;
+
+const getDefaultBirdPhotoDataUrl = () => {
+ if (defaultBirdPhotoDataUrl) {
+ return defaultBirdPhotoDataUrl;
+ }
+
+ const defaultPhotoPath = process.env.DEFAULT_BIRD_PHOTO_PATH?.trim() || path.join(process.cwd(), 'assets', 'yoda.png');
+
+ try {
+ defaultBirdPhotoDataUrl = `data:image/png;base64,${readFileSync(defaultPhotoPath).toString('base64')}`;
+ return defaultBirdPhotoDataUrl;
+ } catch (error) {
+ console.warn(`Unable to load default bird photo from ${defaultPhotoPath}`, error);
+ return null;
+ }
+};
+
const sendRescueStatusNotification = async ({
workspace,
ownerEmail,
@@ -1004,6 +1070,206 @@ const sendLostBirdReportNotification = async ({
return { delivered: true };
};
+const buildBirdMilestoneReminderCopy = (reminder: BirdMilestoneReminderCandidateRow) => {
+ const yearCount = getMilestoneYearCount(reminder);
+ const anniversaryLabel = yearCount > 0 ? formatOrdinal(yearCount) : '';
+
+ if (reminder.reminder_type === 'hatch_day') {
+ return {
+ subject: `Happy Hatch Day, ${reminder.name}!`,
+ eyebrow: 'Hatch Day',
+ headline: `Happy Hatch Day, ${reminder.name}!`,
+ eventName: 'Hatch Day',
+ intro:
+ yearCount > 0
+ ? `From our flock to yours, wishing ${reminder.name} a happy Hatch Day! ${reminder.name} is ${yearCount} year${yearCount === 1 ? '' : 's'} old today.`
+ : `${reminder.name} has a Hatch Day today.`,
+ body: 'Cue the favorite snacks, extra head scritches if approved, and one tiny spotlight for the bird of the day.',
+ milestoneLabel: yearCount > 0 ? `${yearCount} year${yearCount === 1 ? '' : 's'} old` : 'Hatch Day on file',
+ signoff: 'FlockPal is keeping the milestone warm in the flock record.',
+ };
+ }
+
+ return {
+ subject: `It's ${reminder.name}'s Gotcha Day!`,
+ eyebrow: 'Gotcha Day',
+ headline: `It's ${reminder.name}'s Gotcha Day!`,
+ eventName: 'Gotcha Day',
+ intro:
+ yearCount > 0
+ ? `From our flock to yours, wishing ${reminder.name} a happy Gotcha Day! ${reminder.name} joined the flock ${yearCount} year${yearCount === 1 ? '' : 's'} ago today.`
+ : `${reminder.name} has a Gotcha Day today.`,
+ body: 'A good day to remember the first ride home, the first brave perch, and every little routine built since.',
+ milestoneLabel: yearCount > 0 ? `${formatOrdinal(yearCount)} Gotcha Day` : 'Gotcha Day on file',
+ signoff: 'FlockPal saved the date so the flock can celebrate the story.',
+ };
+};
+
+const sendBirdMilestoneReminderNotification = async ({
+ reminder,
+ recipients,
+}: {
+ reminder: BirdMilestoneReminderCandidateRow;
+ recipients: string[];
+}) => {
+ const uniqueRecipients = Array.from(new Set(recipients.map((email) => normalizeEmail(email)).filter(Boolean)));
+
+ if (!uniqueRecipients.length) {
+ return { delivered: false };
+ }
+
+ const copy = buildBirdMilestoneReminderCopy(reminder);
+ const yearCount = getMilestoneYearCount(reminder);
+ const logoDataUrl = getFlockPalLogoDataUrl();
+ const birdPhotoSrc = reminder.photo_data_url || getDefaultBirdPhotoDataUrl();
+ const birdPhotoHtml = birdPhotoSrc
+ ? `
`
+ : `
${escapeHtml(reminder.name.slice(0, 1).toUpperCase())}
`;
+ const milestoneCountLine = `${copy.eyebrow}: ${copy.milestoneLabel}`;
+ const lines = [
+ copy.headline,
+ '',
+ copy.intro,
+ copy.body,
+ '',
+ `Bird: ${reminder.name}`,
+ `Species: ${reminder.species}`,
+ `Flock: ${reminder.workspace_name}`,
+ milestoneCountLine,
+ '',
+ `Open FlockPal: ${frontendBaseUrl}`,
+ ];
+
+ if (!mailTransport) {
+ console.log(`Bird milestone reminder for ${uniqueRecipients.join(', ')}:\n${lines.join('\n')}`);
+ return { delivered: false };
+ }
+
+ await mailTransport.sendMail({
+ from: smtpFromName ? `"${smtpFromName}" <${smtpFromEmail}>` : smtpFromEmail,
+ to: smtpFromEmail,
+ bcc: uniqueRecipients,
+ subject: copy.subject,
+ text: lines.join('\n'),
+ html: `
+
+
+
+

+
+
+
+
+ |
+ ${birdPhotoHtml}
+ |
+
+ ${escapeHtml(copy.eyebrow)}
+ ${escapeHtml(copy.headline)}
+ ${escapeHtml(copy.intro)}
+ |
+
+
+
${escapeHtml(copy.body)}
+
+
Bird: ${escapeHtml(reminder.name)}
+
Species: ${escapeHtml(reminder.species)}
+
Flock: ${escapeHtml(reminder.workspace_name)}
+
${escapeHtml(copy.eventName)}: ${escapeHtml(copy.milestoneLabel)}
+
+
${escapeHtml(copy.signoff)}
+
+ Open FlockPal
+
+
+
+
+ `,
+ });
+
+ return { delivered: true };
+};
+
+const runBirdMilestoneReminders = async (runDate = getDateInTimeZone()) => {
+ const reminders = await listDueBirdMilestoneReminders(runDate);
+ let sent = 0;
+ let skipped = 0;
+ let failed = 0;
+
+ for (const reminder of reminders) {
+ try {
+ const recipients = await listWorkspaceNotificationEmails(reminder.workspace_id);
+ const result = await sendBirdMilestoneReminderNotification({ reminder, recipients });
+
+ if (!result.delivered) {
+ skipped += 1;
+ continue;
+ }
+
+ const delivery = await createBirdMilestoneReminderDelivery({
+ birdId: reminder.id,
+ workspaceId: reminder.workspace_id,
+ reminderType: reminder.reminder_type,
+ reminderYear: reminder.reminder_year,
+ deliveredOn: runDate,
+ });
+
+ if (delivery) {
+ sent += 1;
+ } else {
+ skipped += 1;
+ }
+ } catch (error) {
+ failed += 1;
+ console.error(`Unable to send ${reminder.reminder_type} reminder for bird ${reminder.id}`, error);
+ }
+ }
+
+ return {
+ runDate,
+ checked: reminders.length,
+ sent,
+ skipped,
+ failed,
+ };
+};
+
+let lastMilestoneReminderRunDate = '';
+
+const startBirdMilestoneReminderScheduler = () => {
+ if (!milestoneRemindersEnabled) {
+ console.log('Bird milestone reminders are disabled.');
+ return;
+ }
+
+ const runIfNeeded = async () => {
+ const runDate = getDateInTimeZone();
+ if (lastMilestoneReminderRunDate === runDate) {
+ return;
+ }
+
+ lastMilestoneReminderRunDate = runDate;
+ const result = await runBirdMilestoneReminders(runDate);
+ console.log(
+ `Bird milestone reminders completed for ${result.runDate}: checked=${result.checked}, sent=${result.sent}, skipped=${result.skipped}, failed=${result.failed}`,
+ );
+ };
+
+ setTimeout(() => {
+ void runIfNeeded().catch((error) => {
+ lastMilestoneReminderRunDate = '';
+ console.error('Bird milestone reminder scheduler failed', error);
+ });
+ }, 15_000);
+
+ setInterval(() => {
+ void runIfNeeded().catch((error) => {
+ lastMilestoneReminderRunDate = '';
+ console.error('Bird milestone reminder scheduler failed', error);
+ });
+ }, milestoneReminderCheckIntervalMs);
+};
+
const readBearerToken = (authorizationHeader?: string) => {
if (!authorizationHeader) {
return '';
@@ -2358,6 +2624,26 @@ app.post('/api/birds/:birdId/medications/:medicationId/administrations', require
}
});
+app.post('/api/admin/reminders/bird-milestones/run', requireAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
+ const parsed = z
+ .object({
+ runDate: dateStringSchema.optional(),
+ })
+ .safeParse(req.body ?? {});
+
+ if (!parsed.success) {
+ res.status(400).json({ error: 'Invalid reminder run payload', details: parsed.error.flatten() });
+ return;
+ }
+
+ try {
+ const result = await runBirdMilestoneReminders(parsed.data.runDate ?? getDateInTimeZone());
+ res.json(result);
+ } catch (error) {
+ next(error);
+ }
+});
+
app.use((error: unknown, _req: Request, res: Response, _next: NextFunction) => {
console.error(error);
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
@@ -2368,6 +2654,7 @@ const start = async () => {
app.listen(port, () => {
console.log(`FlockPal backend listening on port ${port}`);
});
+ startBirdMilestoneReminderScheduler();
};
start().catch((error) => {
diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts
index c239c57..5582963 100644
--- a/backend/src/db/schema.ts
+++ b/backend/src/db/schema.ts
@@ -299,6 +299,17 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE medications
ADD COLUMN IF NOT EXISTS dose_schedule JSONB NOT NULL DEFAULT '[{"key":"dose-1","label":"Dose","time":""}]'::jsonb;
+ CREATE TABLE IF NOT EXISTS bird_milestone_reminder_deliveries (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ bird_id UUID NOT NULL REFERENCES birds(id) ON DELETE CASCADE,
+ workspace_id INTEGER NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
+ reminder_type VARCHAR(24) NOT NULL CHECK (reminder_type IN ('hatch_day', 'gotcha_day')),
+ reminder_year INTEGER NOT NULL,
+ delivered_on DATE NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ UNIQUE (bird_id, reminder_type, reminder_year)
+ );
+
CREATE TABLE IF NOT EXISTS medication_administrations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
medication_id UUID NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
@@ -329,6 +340,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_medications_bird_start_date
ON medications (bird_id, start_date DESC);
+ CREATE INDEX IF NOT EXISTS idx_bird_milestone_reminder_deliveries_workspace
+ ON bird_milestone_reminder_deliveries (workspace_id, delivered_on DESC);
+
CREATE INDEX IF NOT EXISTS idx_medication_administrations_bird_administered_on
ON medication_administrations (bird_id, administered_on DESC);
diff --git a/backend/src/repositories/birdRepository.ts b/backend/src/repositories/birdRepository.ts
index fe161b1..7eb4931 100644
--- a/backend/src/repositories/birdRepository.ts
+++ b/backend/src/repositories/birdRepository.ts
@@ -1,6 +1,9 @@
import { db } from '../db/client.js';
import type {
BirdGender,
+ BirdMilestoneReminderCandidateRow,
+ BirdMilestoneReminderDeliveryRow,
+ BirdMilestoneReminderType,
BirdRow,
LostBirdMatchRow,
MedicationAdministrationRow,
@@ -93,6 +96,98 @@ export const findBirdsByBandId = async (tagId: string) => {
return result.rows;
};
+export const listDueBirdMilestoneReminders = async (runDate: string) => {
+ const result = await db.query(
+ `WITH reminder_context AS (
+ SELECT $1::date AS run_date,
+ EXTRACT(YEAR FROM $1::date)::int AS reminder_year
+ )
+ SELECT
+ ${birdSelectFields},
+ workspaces.name AS workspace_name,
+ 'hatch_day'::text AS reminder_type,
+ birds.date_of_birth::text AS reminder_date,
+ reminder_context.reminder_year
+ FROM birds
+ INNER JOIN workspaces ON workspaces.id = birds.workspace_id
+ CROSS JOIN reminder_context
+ LEFT JOIN LATERAL (
+ SELECT weight_grams, recorded_on
+ FROM weight_records
+ WHERE weight_records.bird_id = birds.id
+ ORDER BY recorded_on DESC
+ LIMIT 1
+ ) latest ON TRUE
+ WHERE birds.notify_on_dob = TRUE
+ AND birds.date_of_birth IS NOT NULL
+ AND EXTRACT(MONTH FROM birds.date_of_birth) = EXTRACT(MONTH FROM reminder_context.run_date)
+ AND EXTRACT(DAY FROM birds.date_of_birth) = EXTRACT(DAY FROM reminder_context.run_date)
+ AND NOT EXISTS (
+ SELECT 1
+ FROM bird_milestone_reminder_deliveries deliveries
+ WHERE deliveries.bird_id = birds.id
+ AND deliveries.reminder_type = 'hatch_day'
+ AND deliveries.reminder_year = reminder_context.reminder_year
+ )
+ UNION ALL
+ SELECT
+ ${birdSelectFields},
+ workspaces.name AS workspace_name,
+ 'gotcha_day'::text AS reminder_type,
+ birds.gotcha_day::text AS reminder_date,
+ reminder_context.reminder_year
+ FROM birds
+ INNER JOIN workspaces ON workspaces.id = birds.workspace_id
+ CROSS JOIN reminder_context
+ LEFT JOIN LATERAL (
+ SELECT weight_grams, recorded_on
+ FROM weight_records
+ WHERE weight_records.bird_id = birds.id
+ ORDER BY recorded_on DESC
+ LIMIT 1
+ ) latest ON TRUE
+ WHERE birds.notify_on_gotcha_day = TRUE
+ AND birds.gotcha_day IS NOT NULL
+ AND EXTRACT(MONTH FROM birds.gotcha_day) = EXTRACT(MONTH FROM reminder_context.run_date)
+ AND EXTRACT(DAY FROM birds.gotcha_day) = EXTRACT(DAY FROM reminder_context.run_date)
+ AND NOT EXISTS (
+ SELECT 1
+ FROM bird_milestone_reminder_deliveries deliveries
+ WHERE deliveries.bird_id = birds.id
+ AND deliveries.reminder_type = 'gotcha_day'
+ AND deliveries.reminder_year = reminder_context.reminder_year
+ )
+ ORDER BY workspace_name ASC, name ASC, reminder_type ASC`,
+ [runDate],
+ );
+
+ return result.rows;
+};
+
+export const createBirdMilestoneReminderDelivery = async ({
+ birdId,
+ workspaceId,
+ reminderType,
+ reminderYear,
+ deliveredOn,
+}: {
+ birdId: string;
+ workspaceId: number;
+ reminderType: BirdMilestoneReminderType;
+ reminderYear: number;
+ deliveredOn: string;
+}) => {
+ const result = await db.query(
+ `INSERT INTO bird_milestone_reminder_deliveries (bird_id, workspace_id, reminder_type, reminder_year, delivered_on)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (bird_id, reminder_type, reminder_year) DO NOTHING
+ RETURNING id, bird_id, workspace_id, reminder_type, reminder_year, delivered_on::text, created_at`,
+ [birdId, workspaceId, reminderType, reminderYear, deliveredOn],
+ );
+
+ return result.rows[0] ?? null;
+};
+
export const createBird = async ({
workspaceId,
name,
diff --git a/backend/src/types.ts b/backend/src/types.ts
index ee6f06c..7673fb6 100644
--- a/backend/src/types.ts
+++ b/backend/src/types.ts
@@ -115,6 +115,25 @@ export type LostBirdMatchRow = BirdRow & {
workspace_billing_email: string | null;
};
+export type BirdMilestoneReminderType = 'hatch_day' | 'gotcha_day';
+
+export type BirdMilestoneReminderCandidateRow = BirdRow & {
+ workspace_name: string;
+ reminder_type: BirdMilestoneReminderType;
+ reminder_date: string;
+ reminder_year: number;
+};
+
+export type BirdMilestoneReminderDeliveryRow = {
+ id: string;
+ bird_id: string;
+ workspace_id: number;
+ reminder_type: BirdMilestoneReminderType;
+ reminder_year: number;
+ delivered_on: string;
+ created_at: string;
+};
+
export type PendingBirdTransferRow = {
id: string;
bird_id: string;
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index d746d2b..380f663 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -33,6 +33,9 @@ services:
BACKEND_URL: ${BACKEND_URL:?set BACKEND_URL for production}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
+ DEFAULT_BIRD_PHOTO_PATH: ${DEFAULT_BIRD_PHOTO_PATH:-/app/assets/yoda.png}
+ MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
+ MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
diff --git a/docker-compose.yml b/docker-compose.yml
index 86c0614..d11260e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -32,6 +32,9 @@ services:
BACKEND_URL: ${BACKEND_URL:-http://localhost:5000}
ADMIN_EMAILS: ${ADMIN_EMAILS:-}
RESCUE_STATUS_NOTIFICATION_EMAIL: ${RESCUE_STATUS_NOTIFICATION_EMAIL:-appadmin@flockpal.app}
+ DEFAULT_BIRD_PHOTO_PATH: ${DEFAULT_BIRD_PHOTO_PATH:-/app/assets/yoda.png}
+ MILESTONE_REMINDERS_ENABLED: ${MILESTONE_REMINDERS_ENABLED:-true}
+ MILESTONE_REMINDER_TIME_ZONE: ${MILESTONE_REMINDER_TIME_ZONE:-America/New_York}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-}
MICROSOFT_CLIENT_ID: ${MICROSOFT_CLIENT_ID:-}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index acaef86..e7a08bf 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import flockPalLandingArt from './assets/flockpal-landing-art.png';
+import defaultBirdPhoto from './assets/yoda.png';
import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference';
type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw';
@@ -3870,13 +3871,7 @@ function App() {
>