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)}` + : `
${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: ` +
+
+
+ FlockPal +
+
+ + + + + +
+ ${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() { >