From a5029662932a6159ef876cf4e5f299c6508ce6af Mon Sep 17 00:00:00 2001 From: blaisadmin Date: Thu, 21 May 2026 22:10:51 -0400 Subject: [PATCH] Adding educational components --- backend/src/app.ts | 198 ++++++ backend/src/db/schema.ts | 34 ++ .../src/repositories/educationRepository.ts | 153 +++++ backend/src/types.ts | 29 + frontend/src/App.tsx | 569 +++++++++++++++++- frontend/src/index.css | 122 ++++ 6 files changed, 1103 insertions(+), 2 deletions(-) create mode 100644 backend/src/repositories/educationRepository.ts diff --git a/backend/src/app.ts b/backend/src/app.ts index f8b8145..a8df99d 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, @@ -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; @@ -498,6 +534,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 +2397,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..390ee05 100644 --- a/backend/src/types.ts +++ b/backend/src/types.ts @@ -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/frontend/src/App.tsx b/frontend/src/App.tsx index d29a6a9..3d3b96c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 }, @@ -1410,6 +1451,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 +2007,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 +2254,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 +2283,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 +2729,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 +4806,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 +5024,168 @@ function App() { )}
+ +
+
+
+

Education

+

Fid facts

+
+ +
+
+
+ +