diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/.env.example b/.env.example index c12db60..1ed0bfe 100644 --- a/.env.example +++ b/.env.example @@ -29,9 +29,12 @@ STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY= STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK= STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY= STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY= -STRIPE_PRICE_HOUSEHOLD_MACAW= -STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY= -STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY= +STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY= +STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY= +STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY= +STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW= +STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY= +STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY= STRIPE_CHECKOUT_SUCCESS_URL=http://localhost:3000/?billing=success STRIPE_CHECKOUT_CANCEL_URL=http://localhost:3000/?billing=cancelled STRIPE_PORTAL_RETURN_URL=http://localhost:3000/?billing=portal diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 3fc2944..342a33d 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -20,7 +20,8 @@ jobs: set -e cd /docker/FlockPal-dev git fetch --all --prune - git pull --ff-only + git reset --hard "origin/${{ github.ref_name }}" + git clean -fd - name: Validate backend run: | @@ -58,7 +59,8 @@ jobs: set -e cd /docker/FlockPal git fetch --all --prune - git pull --ff-only + git reset --hard "origin/${{ github.ref_name }}" + git clean -fd - name: Validate backend run: | diff --git a/README.md b/README.md index 9cb26ff..1fdd36e 100644 --- a/README.md +++ b/README.md @@ -203,8 +203,10 @@ Set these when the Stripe Checkout, Customer Portal, and webhook flow is enabled - `STRIPE_PRICE_HOUSEHOLD_CONURE_YEARLY` - `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK` - `STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY` -- `STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_MACAW` -- `STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY` +- `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY` +- `STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY` +- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY` or `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW` +- `STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY` - `STRIPE_CHECKOUT_SUCCESS_URL` - `STRIPE_CHECKOUT_CANCEL_URL` - `STRIPE_PORTAL_RETURN_URL` @@ -221,7 +223,7 @@ Recommended defaults: - Enable the proration behavior you want in the Customer Portal configuration. FlockPal treats Stripe as the source of truth for upgrade timing and proration outcomes. - Point the Stripe webhook endpoint at `https://your-backend-host/api/billing/stripe/webhook`. - Subscribe the webhook endpoint to at least `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`. -- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, and `household_macaw`. +- Use one Stripe Price per plan and billing interval. FlockPal maps Stripe price IDs back to `household_basic`, `household_plus`, `household_macaw`, and `household_hyacinth_macaw`. - After Stripe redirects back to the app, FlockPal now performs a direct billing sync against Stripe and then refreshes the active session. Webhooks are still required so asynchronous subscription changes stay in sync later. For local development with the Stripe CLI: diff --git a/backend/src/app.ts b/backend/src/app.ts index f8b8145..0b8afa7 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -61,6 +61,19 @@ import { updateVetVisitForBird, } from './repositories/birdRepository.js'; import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.js'; +import { + deleteDailyEducation, + deleteEducationQuestion, + createEducationQuestion, + getDailyEducationForDate, + getEducationOptOut, + listDailyEducationForAdmin, + listDailyEducationQuestions, + listEducationQuestionsForAdmin, + updateEducationOptOut, + updateEducationQuestion, + upsertDailyEducation, +} from './repositories/educationRepository.js'; import { buildBirdPhotoObjectKey, getImageExtensionFromContentType, @@ -98,6 +111,8 @@ import type { AuthContext, BillingInterval, BillingPlan, + DailyEducationRow, + EducationQuestionRow, BirdGender, BirdMilestoneReminderCandidateRow, BirdRow, @@ -184,7 +199,7 @@ const switchWorkspaceSchema = z.object({ const workspaceTypeSchema = z.enum(['standard', 'rescue']); const workspaceRoleSchema = z.enum(['owner', 'assistant', 'caregiver', 'viewer']); -const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw']); +const billingPlanSchema = z.enum(['household_basic', 'household_plus', 'household_macaw', 'household_hyacinth_macaw']); const billingIntervalSchema = z.enum(['monthly', 'yearly']); const integrationTokenScopeSchema = z.enum(['read_only', 'read_write']); const birdGenderSchema = z.enum(['unknown', 'male', 'female']); @@ -323,6 +338,27 @@ const integrationTokenCreateSchema = z.object({ expiresInDays: z.coerce.number().int().min(1).max(3650).optional(), }); +const educationQuestionSchema = z + .object({ + prompt: z.string().trim().min(1).max(500), + options: z.array(z.string().trim().min(1).max(240)).min(2).max(4), + correctAnswerIndex: z.coerce.number().int().min(0).max(3), + explanation: z.string().trim().max(800).optional().or(z.literal('')), + }) + .refine((value) => value.correctAnswerIndex < value.options.length, { + message: 'Correct answer must match one of the quiz options.', + path: ['correctAnswerIndex'], + }); + +const dailyEducationSchema = z.object({ + publishDate: dateStringSchema, + fact: z.string().trim().min(1).max(2000), +}); + +const educationPreferenceSchema = z.object({ + educationOptOut: z.boolean(), +}); + const emptyToNull = (value?: string) => { const trimmed = value?.trim() ?? ''; return trimmed ? trimmed : null; @@ -347,12 +383,16 @@ const createCodeChallenge = (verifier: string) => crypto.createHash('sha256').up const resolveBillingPlan = ( workspaceType: WorkspaceType, - requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw', + requestedPlan?: BillingPlan | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw', ) => { if (workspaceType === 'rescue') { return 'rescue_free' as const; } + if (requestedPlan === 'household_hyacinth_macaw') { + return 'household_hyacinth_macaw'; + } + if (requestedPlan === 'household_macaw') { return 'household_macaw'; } @@ -404,8 +444,18 @@ const stripePriceByBillingPlanAndInterval: Partial, Record> = { @@ -418,14 +468,19 @@ const stripePriceEnvNamesByBillingPlanAndInterval: Record, string> = { household_basic: 'Conure', household_plus: 'Indian Ringneck', - household_macaw: 'Macaw', + household_macaw: 'African Grey', + household_hyacinth_macaw: 'Hyacinth Macaw', }; const stripe = stripeSecretKey ? new Stripe(stripeSecretKey) : null; const adminEmails = new Set( @@ -498,6 +553,25 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({ createdAt: row.created_at, }); +const normalizeEducationQuestion = (row: EducationQuestionRow) => ({ + id: row.id, + prompt: row.prompt, + options: row.options, + correctAnswerIndex: Number(row.correct_answer_index), + explanation: row.explanation ?? null, + createdAt: row.created_at, + updatedAt: row.updated_at, +}); + +const normalizeDailyEducation = (row: DailyEducationRow, questions: EducationQuestionRow[] = []) => ({ + id: row.id, + publishDate: row.publish_date, + fact: row.fact, + quizQuestions: questions.map(normalizeEducationQuestion), + createdAt: row.created_at, + updatedAt: row.updated_at, +}); + const signBirdPhotoAccessToken = (row: BirdRow) => { if (!row.photo_object_key) { return ''; @@ -2342,6 +2416,149 @@ app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, require } }); +app.get('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { + try { + const education = await listDailyEducationForAdmin(); + res.json({ education: education.map((entry) => normalizeDailyEducation(entry)) }); + } catch (error) { + next(error); + } +}); + +app.get('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => { + try { + const questions = await listEducationQuestionsForAdmin(); + res.json({ questions: questions.map(normalizeEducationQuestion) }); + } catch (error) { + next(error); + } +}); + +app.put('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { + const parsed = dailyEducationSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid daily education payload', details: parsed.error.flatten() }); + return; + } + + try { + const education = await upsertDailyEducation({ + publishDate: parsed.data.publishDate, + fact: parsed.data.fact, + createdByUserId: req.auth!.user.id, + }); + res.json({ education: normalizeDailyEducation(education) }); + } catch (error) { + next(error); + } +}); + +app.post('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { + const parsed = educationQuestionSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() }); + return; + } + + try { + const question = await createEducationQuestion({ + question: { ...parsed.data, explanation: emptyToNull(parsed.data.explanation) }, + createdByUserId: req.auth!.user.id, + }); + res.status(201).json({ question: normalizeEducationQuestion(question) }); + } catch (error) { + next(error); + } +}); + +app.put('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { + const parsed = educationQuestionSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() }); + return; + } + + try { + const question = await updateEducationQuestion(req.params.questionId, { + ...parsed.data, + explanation: emptyToNull(parsed.data.explanation), + }); + + if (!question) { + res.status(404).json({ error: 'Education question not found.' }); + return; + } + + res.json({ question: normalizeEducationQuestion(question) }); + } catch (error) { + next(error); + } +}); + +app.delete('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { + try { + const deleted = await deleteEducationQuestion(req.params.questionId); + + if (!deleted) { + res.status(404).json({ error: 'Education question not found.' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +app.delete('/api/admin/daily-education/:educationId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => { + try { + const deleted = await deleteDailyEducation(req.params.educationId); + + if (!deleted) { + res.status(404).json({ error: 'Daily education item not found.' }); + return; + } + + res.status(204).send(); + } catch (error) { + next(error); + } +}); + +app.get('/api/education/today', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { + try { + const educationOptOut = await getEducationOptOut(req.auth!.user.id); + const education = educationOptOut ? null : await getDailyEducationForDate(); + const questions = education ? await listDailyEducationQuestions(education.publish_date) : []; + + res.json({ + educationOptOut, + education: education ? normalizeDailyEducation(education, questions) : null, + }); + } catch (error) { + next(error); + } +}); + +app.patch('/api/education/preferences', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => { + const parsed = educationPreferenceSchema.safeParse(req.body); + + if (!parsed.success) { + res.status(400).json({ error: 'Invalid education preference payload', details: parsed.error.flatten() }); + return; + } + + try { + const educationOptOut = await updateEducationOptOut(req.auth!.user.id, parsed.data.educationOptOut); + res.json({ educationOptOut }); + } catch (error) { + next(error); + } +}); + app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => { const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body); diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 875fd1d..beb31c0 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -30,6 +30,9 @@ export const ensureSchema = async (database: DatabaseClient = db) => { ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_id_check; + ALTER TABLE users + ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE; + ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255), ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic', @@ -139,6 +142,37 @@ export const ensureSchema = async (database: DatabaseClient = db) => { CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user ON auth_sessions (created_at DESC, user_id); + CREATE TABLE IF NOT EXISTS daily_education ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + publish_date DATE NOT NULL UNIQUE, + fact TEXT NOT NULL, + quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb, + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP + ); + + ALTER TABLE daily_education + ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb; + + CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date + ON daily_education (publish_date DESC); + + CREATE TABLE IF NOT EXISTS education_question_bank ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + prompt VARCHAR(500) NOT NULL, + options JSONB NOT NULL, + correct_answer_index INTEGER NOT NULL, + explanation VARCHAR(800), + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3) + ); + + CREATE INDEX IF NOT EXISTS idx_education_question_bank_created + ON education_question_bank (created_at DESC); + CREATE TABLE IF NOT EXISTS integration_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, diff --git a/backend/src/repositories/educationRepository.ts b/backend/src/repositories/educationRepository.ts new file mode 100644 index 0000000..817a07e --- /dev/null +++ b/backend/src/repositories/educationRepository.ts @@ -0,0 +1,153 @@ +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( + `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( + `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( + `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( + `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( + `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( + `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( + `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( + `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); +}; diff --git a/backend/src/types.ts b/backend/src/types.ts index dc576f5..97c2e22 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -1,6 +1,6 @@ export type WorkspaceType = 'standard' | 'rescue'; export type WorkspaceRole = 'owner' | 'assistant' | 'caregiver' | 'viewer'; -export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; +export type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw'; export type BillingInterval = 'monthly' | 'yearly'; export type SubscriptionStatus = 'active' | 'trialing' | 'past_due' | 'canceled' | 'unpaid' | 'none'; export type RescueVerificationStatus = 'not_required' | 'pending' | 'approved' | 'rejected'; @@ -13,9 +13,38 @@ export type UserRow = { email: string; password_hash: string | null; name: string; + education_opt_out?: boolean; created_at: string; }; +export type DailyEducationQuestion = { + prompt: string; + options: string[]; + correctAnswerIndex: number; + explanation: string | null; +}; + +export type DailyEducationRow = { + id: string; + publish_date: string; + fact: string; + quiz_questions: DailyEducationQuestion[]; + created_by_user_id: string | null; + created_at: string; + updated_at: string; +}; + +export type EducationQuestionRow = { + id: string; + prompt: string; + options: string[]; + correct_answer_index: number; + explanation: string | null; + created_by_user_id: string | null; + created_at: string; + updated_at: string; +}; + export type WorkspaceRow = { id: number; name: string; diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 167a94b..78a6a71 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -73,9 +73,12 @@ services: STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-} - STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-} - STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-} - STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-} + STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY:-} + STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY:-} + STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY:-} + STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW:-} + STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY:-} + STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY:-} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-${FRONTEND_URL}/?billing=success} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-${FRONTEND_URL}/?billing=cancelled} STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-${FRONTEND_URL}/?billing=portal} diff --git a/docker-compose.yml b/docker-compose.yml index b7cd0cc..dc25cec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -71,9 +71,12 @@ services: STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_MONTHLY:-} STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_INDIANRINGNECK_YEARLY:-} - STRIPE_PRICE_HOUSEHOLD_MACAW: ${STRIPE_PRICE_HOUSEHOLD_MACAW:-} - STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_MONTHLY:-} - STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_MACAW_YEARLY:-} + STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY:-} + STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_MONTHLY:-} + STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_AFRICAN_GREY_YEARLY:-} + STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW:-} + STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_MONTHLY:-} + STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY: ${STRIPE_PRICE_HOUSEHOLD_HYACINTH_MACAW_YEARLY:-} STRIPE_CHECKOUT_SUCCESS_URL: ${STRIPE_CHECKOUT_SUCCESS_URL:-http://localhost:3000/?billing=success} STRIPE_CHECKOUT_CANCEL_URL: ${STRIPE_CHECKOUT_CANCEL_URL:-http://localhost:3000/?billing=cancelled} STRIPE_PORTAL_RETURN_URL: ${STRIPE_PORTAL_RETURN_URL:-http://localhost:3000/?billing=portal} diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 14e18fd..a1736a0 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -653,7 +653,7 @@ Request body: Notes: - `workspaceType` must be `standard` or `rescue` -- `billingPlan` may be `household_basic`, `household_plus`, or `household_macaw` +- `billingPlan` may be `household_basic`, `household_plus`, `household_macaw`, or `household_hyacinth_macaw` - rescue workspaces are forced to `rescue_free` Response `201`: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d29a6a9..ec1a70e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,7 @@ import defaultBirdPhoto from './assets/yoda-default.png'; import { findParrotWeightReference, parrotSpeciesOptions, type ParrotWeightReference } from './parrotWeightReference'; import QRCode from 'qrcode'; -type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw'; +type BillingPlan = 'rescue_free' | 'household_basic' | 'household_plus' | 'household_macaw' | 'household_hyacinth_macaw'; type HouseholdBillingPlan = Exclude; type BillingInterval = 'monthly' | 'yearly'; type WorkspaceType = 'standard' | 'rescue'; @@ -162,6 +162,23 @@ type AdminRescueWorkspace = { memberCount: number; }; +type DailyEducationQuestion = { + id: string; + prompt: string; + options: string[]; + correctAnswerIndex: number; + explanation: string | null; +}; + +type DailyEducation = { + id: string; + publishDate: string; + fact: string; + quizQuestions: DailyEducationQuestion[]; + createdAt: string; + updatedAt: string; +}; + type IntegrationTokenSummary = { id: string; userId: string; @@ -294,6 +311,18 @@ type BillingNotice = { message: string; }; +type DailyEducationQuestionFormState = { + prompt: string; + options: [string, string, string, string]; + correctAnswerIndex: number; + explanation: string; +}; + +type DailyEducationFormState = { + publishDate: string; + fact: string; +}; + type BulkWeightRowState = { weightGrams: string; }; @@ -661,6 +690,18 @@ const emptyIntegrationTokenForm: IntegrationTokenFormState = { expiresInDays: '', }; +const emptyDailyEducationQuestion = (): DailyEducationQuestionFormState => ({ + prompt: '', + options: ['', '', '', ''], + correctAnswerIndex: 0, + explanation: '', +}); + +const emptyDailyEducationForm = (): DailyEducationFormState => ({ + publishDate: new Date().toISOString().slice(0, 10), + fact: '', +}); + const defaultAuthProviders: AuthProvider[] = [ { providerKey: 'google', displayName: 'Google', enabled: false }, { providerKey: 'microsoft', displayName: 'Microsoft', enabled: false }, @@ -937,7 +978,7 @@ const oauthStartUrl = (providerKey: AuthProvider['providerKey']) => { }; const isHouseholdPlan = (billingPlan: BillingPlan): billingPlan is HouseholdBillingPlan => - billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw'; + billingPlan === 'household_basic' || billingPlan === 'household_plus' || billingPlan === 'household_macaw' || billingPlan === 'household_hyacinth_macaw'; const formatBillingIntervalName = (billingInterval: BillingInterval) => (billingInterval === 'yearly' ? 'Annual' : 'Monthly'); @@ -954,7 +995,11 @@ const formatBillingPlanName = (billingPlan: BillingPlan) => { return 'Indian Ringneck'; } - return 'Macaw'; + if (billingPlan === 'household_macaw') { + return 'African Grey'; + } + + return 'Hyacinth Macaw'; }; const formatBillingPlanCapacity = (billingPlan: BillingPlan) => { @@ -967,10 +1012,14 @@ const formatBillingPlanCapacity = (billingPlan: BillingPlan) => { } if (billingPlan === 'household_plus') { - return 'Permits 5 to 10 birds in the flock.'; + return 'Permits 5 to 9 birds in the flock.'; } - return 'Permits 11 or more birds in the flock.'; + if (billingPlan === 'household_macaw') { + return 'Permits 11 to 16 birds in the flock.'; + } + + return 'Permits 17 or more birds in the flock.'; }; const formatBillingPlanDropdownLabel = (billingPlan: HouseholdBillingPlan) => { @@ -979,10 +1028,14 @@ const formatBillingPlanDropdownLabel = (billingPlan: HouseholdBillingPlan) => { } if (billingPlan === 'household_plus') { - return 'Indian Ringneck (10 birds)'; + return 'Indian Ringneck (5-9 birds)'; } - return 'Macaw (11+)'; + if (billingPlan === 'household_macaw') { + return 'African Grey (11-16 birds)'; + } + + return 'Hyacinth Macaw (17+)'; }; const householdPlanPrices: Record> = { @@ -998,6 +1051,10 @@ const householdPlanPrices: Record @@ -1009,11 +1066,15 @@ const formatBillingPlanBirdLimit = (billingPlan: BillingPlan) => { } if (billingPlan === 'household_plus') { - return '10'; + return '9'; } if (billingPlan === 'household_macaw') { - return '11+'; + return '16'; + } + + if (billingPlan === 'household_hyacinth_macaw') { + return '17+'; } return null; @@ -1410,6 +1471,20 @@ function App() { const [integrationTokens, setIntegrationTokens] = useState([]); const [adminSummary, setAdminSummary] = useState(null); const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState([]); + const [adminDailyEducation, setAdminDailyEducation] = useState([]); + const [adminEducationQuestions, setAdminEducationQuestions] = useState([]); + const [dailyEducationForm, setDailyEducationForm] = useState(emptyDailyEducationForm); + const [educationQuestionForm, setEducationQuestionForm] = useState(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(null); + const [educationOptOut, setEducationOptOut] = useState(false); + const [savingEducationPreference, setSavingEducationPreference] = useState(false); + const [educationAnswers, setEducationAnswers] = useState>({}); + const [dailyEducationOpen, setDailyEducationOpen] = useState(false); const [birds, setBirds] = useState([]); const [memorializedBirds, setMemorializedBirds] = useState([]); const [selectedBirdId, setSelectedBirdId] = useState(''); @@ -1952,6 +2027,15 @@ function App() { setIntegrationTokens([]); setAdminSummary(null); setAdminRescueWorkspaces([]); + setAdminDailyEducation([]); + setAdminEducationQuestions([]); + setDailyEducationForm(emptyDailyEducationForm()); + setEducationQuestionForm(emptyDailyEducationQuestion()); + setEditingEducationQuestionId(''); + setTodayEducation(null); + setEducationOptOut(false); + setEducationAnswers({}); + setDailyEducationOpen(false); setBirds([]); setMemorializedBirds([]); setWeights([]); @@ -2190,20 +2274,27 @@ function App() { const loadAdminDashboard = async () => { try { - const [summaryResponse, rescuesResponse] = await Promise.all([ + const [summaryResponse, rescuesResponse, educationResponse, educationQuestionsResponse] = await Promise.all([ apiFetch('/admin/summary', authToken), apiFetch('/admin/rescue-workspaces', authToken), + apiFetch('/admin/daily-education', authToken), + apiFetch('/admin/education-questions', authToken), ]); - if (!summaryResponse.ok || !rescuesResponse.ok) { + if (!summaryResponse.ok || !rescuesResponse.ok || !educationResponse.ok || !educationQuestionsResponse.ok) { throw new Error('Unable to load admin dashboard.'); } const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {}; const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {}; + const educationData = (await readJsonSafely<{ education?: DailyEducation[] }>(educationResponse)) ?? {}; + const educationQuestionsData = + (await readJsonSafely<{ questions?: DailyEducationQuestion[] }>(educationQuestionsResponse)) ?? {}; setAdminSummary(summaryData.summary ?? null); setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []); + setAdminDailyEducation(educationData.education ?? []); + setAdminEducationQuestions(educationQuestionsData.questions ?? []); } catch (adminError) { setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.'); } @@ -2212,6 +2303,34 @@ function App() { void loadAdminDashboard(); }, [activePage, authSession?.isAdmin, authToken]); + useEffect(() => { + if (!authToken || !authSession) { + return; + } + + const loadTodayEducation = async () => { + try { + const response = await apiFetch('/education/today', authToken); + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Unable to load daily education.')); + } + + const data = + (await readJsonSafely<{ education?: DailyEducation | null; educationOptOut?: boolean }>(response)) ?? {}; + + setEducationOptOut(Boolean(data.educationOptOut)); + setTodayEducation(data.education ?? null); + setEducationAnswers({}); + setDailyEducationOpen(false); + } catch (educationError) { + setError(educationError instanceof Error ? educationError.message : 'Unable to load daily education.'); + } + }; + + void loadTodayEducation(); + }, [authSession, authToken]); + useEffect(() => { if (!selectedBird?.id) { setWeights([]); @@ -2630,6 +2749,221 @@ 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) => { + 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) => { + 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) => { event.preventDefault(); @@ -4492,6 +4826,74 @@ function App() { + {todayEducation ? ( +
+
+
+

Fid fact of the day

+

{dailyEducationOpen ? formatDate(todayEducation.publishDate) : 'Daily learning'}

+
+ +
+ {dailyEducationOpen ? ( + <> +

{todayEducation.fact}

+
+ {todayEducation.quizQuestions.map((question, questionIndex) => { + const selectedAnswer = educationAnswers[questionIndex]; + const hasAnswer = selectedAnswer !== undefined; + + return ( +
+ {question.prompt} +
+ {question.options.map((option, optionIndex) => ( + + ))} +
+ {hasAnswer ? ( +

+ {selectedAnswer === question.correctAnswerIndex ? 'Correct.' : 'Correct answer shown.'}{' '} + {question.explanation} +

+ ) : null} +
+ ); + })} +
+ + ) : ( +

+ Open today's fact and {todayEducation.quizQuestions.length || 'the'} quiz questions. +

+ )} +
+ ) : null} +
@@ -4642,6 +5044,168 @@ function App() { )}
+ +
+
+
+

Education

+

Fid facts

+
+ +
+
+
+ +