Revert education feature from main

This commit is contained in:
blaisadmin
2026-05-30 15:50:23 -04:00
parent 505a9b8496
commit e965cb55ef
6 changed files with 2 additions and 1103 deletions
-198
View File
@@ -61,19 +61,6 @@ import {
updateVetVisitForBird, updateVetVisitForBird,
} from './repositories/birdRepository.js'; } from './repositories/birdRepository.js';
import { createIntegrationTokenRecord, listIntegrationTokens, revokeIntegrationToken } from './repositories/integrationTokenRepository.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 { import {
buildBirdPhotoObjectKey, buildBirdPhotoObjectKey,
getImageExtensionFromContentType, getImageExtensionFromContentType,
@@ -112,8 +99,6 @@ import type {
AuthContext, AuthContext,
BillingInterval, BillingInterval,
BillingPlan, BillingPlan,
DailyEducationRow,
EducationQuestionRow,
BirdGender, BirdGender,
BirdMilestoneReminderCandidateRow, BirdMilestoneReminderCandidateRow,
BirdRow, BirdRow,
@@ -339,27 +324,6 @@ const integrationTokenCreateSchema = z.object({
expiresInDays: z.coerce.number().int().min(1).max(3650).optional(), expiresInDays: z.coerce.number().int().min(1).max(3650).optional(),
}); });
const educationQuestionSchema = z
.object({
prompt: z.string().trim().min(1).max(500),
options: z.array(z.string().trim().min(1).max(240)).min(2).max(4),
correctAnswerIndex: z.coerce.number().int().min(0).max(3),
explanation: z.string().trim().max(800).optional().or(z.literal('')),
})
.refine((value) => value.correctAnswerIndex < value.options.length, {
message: 'Correct answer must match one of the quiz options.',
path: ['correctAnswerIndex'],
});
const dailyEducationSchema = z.object({
publishDate: dateStringSchema,
fact: z.string().trim().min(1).max(2000),
});
const educationPreferenceSchema = z.object({
educationOptOut: z.boolean(),
});
const emptyToNull = (value?: string) => { const emptyToNull = (value?: string) => {
const trimmed = value?.trim() ?? ''; const trimmed = value?.trim() ?? '';
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
@@ -554,25 +518,6 @@ const normalizeWorkspaceMember = (row: WorkspaceMemberRow) => ({
createdAt: row.created_at, createdAt: row.created_at,
}); });
const normalizeEducationQuestion = (row: EducationQuestionRow) => ({
id: row.id,
prompt: row.prompt,
options: row.options,
correctAnswerIndex: Number(row.correct_answer_index),
explanation: row.explanation ?? null,
createdAt: row.created_at,
updatedAt: row.updated_at,
});
const normalizeDailyEducation = (row: DailyEducationRow, questions: EducationQuestionRow[] = []) => ({
id: row.id,
publishDate: row.publish_date,
fact: row.fact,
quizQuestions: questions.map(normalizeEducationQuestion),
createdAt: row.created_at,
updatedAt: row.updated_at,
});
const signBirdPhotoAccessToken = (row: BirdRow) => { const signBirdPhotoAccessToken = (row: BirdRow) => {
if (!row.photo_object_key) { if (!row.photo_object_key) {
return ''; return '';
@@ -2437,149 +2382,6 @@ app.get('/api/admin/rescue-workspaces', requireAuth, requireSessionAuth, require
} }
}); });
app.get('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
try {
const education = await listDailyEducationForAdmin();
res.json({ education: education.map((entry) => normalizeDailyEducation(entry)) });
} catch (error) {
next(error);
}
});
app.get('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (_req: Request, res: Response, next: NextFunction) => {
try {
const questions = await listEducationQuestionsForAdmin();
res.json({ questions: questions.map(normalizeEducationQuestion) });
} catch (error) {
next(error);
}
});
app.put('/api/admin/daily-education', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = dailyEducationSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid daily education payload', details: parsed.error.flatten() });
return;
}
try {
const education = await upsertDailyEducation({
publishDate: parsed.data.publishDate,
fact: parsed.data.fact,
createdByUserId: req.auth!.user.id,
});
res.json({ education: normalizeDailyEducation(education) });
} catch (error) {
next(error);
}
});
app.post('/api/admin/education-questions', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = educationQuestionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() });
return;
}
try {
const question = await createEducationQuestion({
question: { ...parsed.data, explanation: emptyToNull(parsed.data.explanation) },
createdByUserId: req.auth!.user.id,
});
res.status(201).json({ question: normalizeEducationQuestion(question) });
} catch (error) {
next(error);
}
});
app.put('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
const parsed = educationQuestionSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid education question payload', details: parsed.error.flatten() });
return;
}
try {
const question = await updateEducationQuestion(req.params.questionId, {
...parsed.data,
explanation: emptyToNull(parsed.data.explanation),
});
if (!question) {
res.status(404).json({ error: 'Education question not found.' });
return;
}
res.json({ question: normalizeEducationQuestion(question) });
} catch (error) {
next(error);
}
});
app.delete('/api/admin/education-questions/:questionId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteEducationQuestion(req.params.questionId);
if (!deleted) {
res.status(404).json({ error: 'Education question not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.delete('/api/admin/daily-education/:educationId', requireAuth, requireSessionAuth, requireAdmin, async (req: Request, res: Response, next: NextFunction) => {
try {
const deleted = await deleteDailyEducation(req.params.educationId);
if (!deleted) {
res.status(404).json({ error: 'Daily education item not found.' });
return;
}
res.status(204).send();
} catch (error) {
next(error);
}
});
app.get('/api/education/today', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const educationOptOut = await getEducationOptOut(req.auth!.user.id);
const education = educationOptOut ? null : await getDailyEducationForDate();
const questions = education ? await listDailyEducationQuestions(education.publish_date) : [];
res.json({
educationOptOut,
education: education ? normalizeDailyEducation(education, questions) : null,
});
} catch (error) {
next(error);
}
});
app.patch('/api/education/preferences', requireAuth, requireSessionAuth, async (req: Request, res: Response, next: NextFunction) => {
const parsed = educationPreferenceSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'Invalid education preference payload', details: parsed.error.flatten() });
return;
}
try {
const educationOptOut = await updateEducationOptOut(req.auth!.user.id, parsed.data.educationOptOut);
res.json({ educationOptOut });
} catch (error) {
next(error);
}
});
app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => { app.patch('/api/admin/rescue-workspaces/:workspaceId', requireAuth, requireAdmin, requireWriteAccess, async (req: Request, res: Response, next: NextFunction) => {
const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body); const parsed = z.object({ rescueVerificationStatus: rescueVerificationStatusSchema }).safeParse(req.body);
-34
View File
@@ -30,9 +30,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
ALTER TABLE workspaces ALTER TABLE workspaces
DROP CONSTRAINT IF EXISTS workspaces_id_check; DROP CONSTRAINT IF EXISTS workspaces_id_check;
ALTER TABLE users
ADD COLUMN IF NOT EXISTS education_opt_out BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE workspaces ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255), ADD COLUMN IF NOT EXISTS billing_email VARCHAR(255),
ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic', ADD COLUMN IF NOT EXISTS billing_plan VARCHAR(32) NOT NULL DEFAULT 'household_basic',
@@ -142,37 +139,6 @@ export const ensureSchema = async (database: DatabaseClient = db) => {
CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user CREATE INDEX IF NOT EXISTS idx_auth_sessions_created_user
ON auth_sessions (created_at DESC, user_id); ON auth_sessions (created_at DESC, user_id);
CREATE TABLE IF NOT EXISTS daily_education (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
publish_date DATE NOT NULL UNIQUE,
fact TEXT NOT NULL,
quiz_questions JSONB NOT NULL DEFAULT '[]'::jsonb,
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP
);
ALTER TABLE daily_education
ALTER COLUMN quiz_questions SET DEFAULT '[]'::jsonb;
CREATE INDEX IF NOT EXISTS idx_daily_education_publish_date
ON daily_education (publish_date DESC);
CREATE TABLE IF NOT EXISTS education_question_bank (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
prompt VARCHAR(500) NOT NULL,
options JSONB NOT NULL,
correct_answer_index INTEGER NOT NULL,
explanation VARCHAR(800),
created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK (correct_answer_index >= 0 AND correct_answer_index <= 3)
);
CREATE INDEX IF NOT EXISTS idx_education_question_bank_created
ON education_question_bank (created_at DESC);
CREATE TABLE IF NOT EXISTS integration_tokens ( CREATE TABLE IF NOT EXISTS integration_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -1,153 +0,0 @@
import { db } from '../db/client.js';
import type { DailyEducationQuestion, DailyEducationRow, EducationQuestionRow } from '../types.js';
export const getEducationOptOut = async (userId: string) => {
const result = await db.query<{ education_opt_out: boolean }>(
`SELECT education_opt_out
FROM users
WHERE id = $1`,
[userId],
);
return result.rows[0]?.education_opt_out ?? false;
};
export const updateEducationOptOut = async (userId: string, educationOptOut: boolean) => {
const result = await db.query<{ education_opt_out: boolean }>(
`UPDATE users
SET education_opt_out = $2
WHERE id = $1
RETURNING education_opt_out`,
[userId, educationOptOut],
);
return result.rows[0]?.education_opt_out ?? educationOptOut;
};
export const getDailyEducationForDate = async (publishDate?: string) => {
const result = publishDate
? await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
WHERE publish_date = $1`,
[publishDate],
)
: await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
WHERE publish_date = CURRENT_DATE`,
);
return result.rows[0] ?? null;
};
export const listDailyEducationForAdmin = async () => {
const result = await db.query<DailyEducationRow>(
`SELECT id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at
FROM daily_education
ORDER BY publish_date DESC
LIMIT 120`,
);
return result.rows;
};
export const upsertDailyEducation = async ({
publishDate,
fact,
createdByUserId,
}: {
publishDate: string;
fact: string;
createdByUserId: string;
}) => {
const result = await db.query<DailyEducationRow>(
`INSERT INTO daily_education (publish_date, fact, created_by_user_id)
VALUES ($1, $2, $3)
ON CONFLICT (publish_date) DO UPDATE
SET fact = EXCLUDED.fact,
updated_at = CURRENT_TIMESTAMP
RETURNING id, publish_date::text, fact, quiz_questions, created_by_user_id, created_at, updated_at`,
[publishDate, fact, createdByUserId],
);
return result.rows[0];
};
export const listEducationQuestionsForAdmin = async () => {
const result = await db.query<EducationQuestionRow>(
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
FROM education_question_bank
ORDER BY updated_at DESC, created_at DESC
LIMIT 400`,
);
return result.rows;
};
export const listDailyEducationQuestions = async (seedDate?: string) => {
const result = await db.query<EducationQuestionRow>(
`SELECT id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at
FROM education_question_bank
ORDER BY md5(COALESCE($1::text, CURRENT_DATE::text) || id::text)
LIMIT 4`,
[seedDate ?? null],
);
return result.rows;
};
export const createEducationQuestion = async ({
question,
createdByUserId,
}: {
question: DailyEducationQuestion;
createdByUserId: string;
}) => {
const result = await db.query<EducationQuestionRow>(
`INSERT INTO education_question_bank (prompt, options, correct_answer_index, explanation, created_by_user_id)
VALUES ($1, $2::jsonb, $3, $4, $5)
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
[question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation, createdByUserId],
);
return result.rows[0];
};
export const updateEducationQuestion = async (questionId: string, question: DailyEducationQuestion) => {
const result = await db.query<EducationQuestionRow>(
`UPDATE education_question_bank
SET prompt = $2,
options = $3::jsonb,
correct_answer_index = $4,
explanation = $5,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, prompt, options, correct_answer_index, explanation, created_by_user_id, created_at, updated_at`,
[questionId, question.prompt, JSON.stringify(question.options), question.correctAnswerIndex, question.explanation],
);
return result.rows[0] ?? null;
};
export const deleteEducationQuestion = async (questionId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM education_question_bank
WHERE id = $1
RETURNING id`,
[questionId],
);
return Boolean(result.rowCount);
};
export const deleteDailyEducation = async (educationId: string) => {
const result = await db.query<{ id: string }>(
`DELETE FROM daily_education
WHERE id = $1
RETURNING id`,
[educationId],
);
return Boolean(result.rowCount);
};
-29
View File
@@ -13,38 +13,9 @@ export type UserRow = {
email: string; email: string;
password_hash: string | null; password_hash: string | null;
name: string; name: string;
education_opt_out?: boolean;
created_at: string; created_at: string;
}; };
export type DailyEducationQuestion = {
prompt: string;
options: string[];
correctAnswerIndex: number;
explanation: string | null;
};
export type DailyEducationRow = {
id: string;
publish_date: string;
fact: string;
quiz_questions: DailyEducationQuestion[];
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
export type EducationQuestionRow = {
id: string;
prompt: string;
options: string[];
correct_answer_index: number;
explanation: string | null;
created_by_user_id: string | null;
created_at: string;
updated_at: string;
};
export type WorkspaceRow = { export type WorkspaceRow = {
id: number; id: number;
name: string; name: string;
+2 -567
View File
@@ -162,23 +162,6 @@ type AdminRescueWorkspace = {
memberCount: number; memberCount: number;
}; };
type DailyEducationQuestion = {
id: string;
prompt: string;
options: string[];
correctAnswerIndex: number;
explanation: string | null;
};
type DailyEducation = {
id: string;
publishDate: string;
fact: string;
quizQuestions: DailyEducationQuestion[];
createdAt: string;
updatedAt: string;
};
type IntegrationTokenSummary = { type IntegrationTokenSummary = {
id: string; id: string;
userId: string; userId: string;
@@ -311,18 +294,6 @@ type BillingNotice = {
message: string; message: string;
}; };
type DailyEducationQuestionFormState = {
prompt: string;
options: [string, string, string, string];
correctAnswerIndex: number;
explanation: string;
};
type DailyEducationFormState = {
publishDate: string;
fact: string;
};
type BulkWeightRowState = { type BulkWeightRowState = {
weightGrams: string; weightGrams: string;
}; };
@@ -690,18 +661,6 @@ const emptyIntegrationTokenForm: IntegrationTokenFormState = {
expiresInDays: '', expiresInDays: '',
}; };
const emptyDailyEducationQuestion = (): DailyEducationQuestionFormState => ({
prompt: '',
options: ['', '', '', ''],
correctAnswerIndex: 0,
explanation: '',
});
const emptyDailyEducationForm = (): DailyEducationFormState => ({
publishDate: new Date().toISOString().slice(0, 10),
fact: '',
});
const defaultAuthProviders: AuthProvider[] = [ const defaultAuthProviders: AuthProvider[] = [
{ providerKey: 'google', displayName: 'Google', enabled: false }, { providerKey: 'google', displayName: 'Google', enabled: false },
{ providerKey: 'microsoft', displayName: 'Microsoft', enabled: false }, { providerKey: 'microsoft', displayName: 'Microsoft', enabled: false },
@@ -1471,20 +1430,6 @@ function App() {
const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]); const [integrationTokens, setIntegrationTokens] = useState<IntegrationTokenSummary[]>([]);
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null); const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]); const [adminRescueWorkspaces, setAdminRescueWorkspaces] = useState<AdminRescueWorkspace[]>([]);
const [adminDailyEducation, setAdminDailyEducation] = useState<DailyEducation[]>([]);
const [adminEducationQuestions, setAdminEducationQuestions] = useState<DailyEducationQuestion[]>([]);
const [dailyEducationForm, setDailyEducationForm] = useState<DailyEducationFormState>(emptyDailyEducationForm);
const [educationQuestionForm, setEducationQuestionForm] = useState<DailyEducationQuestionFormState>(emptyDailyEducationQuestion);
const [editingEducationQuestionId, setEditingEducationQuestionId] = useState('');
const [savingDailyEducation, setSavingDailyEducation] = useState(false);
const [savingEducationQuestion, setSavingEducationQuestion] = useState(false);
const [deletingDailyEducationId, setDeletingDailyEducationId] = useState('');
const [deletingEducationQuestionId, setDeletingEducationQuestionId] = useState('');
const [todayEducation, setTodayEducation] = useState<DailyEducation | null>(null);
const [educationOptOut, setEducationOptOut] = useState(false);
const [savingEducationPreference, setSavingEducationPreference] = useState(false);
const [educationAnswers, setEducationAnswers] = useState<Record<number, number>>({});
const [dailyEducationOpen, setDailyEducationOpen] = useState(false);
const [birds, setBirds] = useState<Bird[]>([]); const [birds, setBirds] = useState<Bird[]>([]);
const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]); const [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
const [selectedBirdId, setSelectedBirdId] = useState<string>(''); const [selectedBirdId, setSelectedBirdId] = useState<string>('');
@@ -2027,15 +1972,6 @@ function App() {
setIntegrationTokens([]); setIntegrationTokens([]);
setAdminSummary(null); setAdminSummary(null);
setAdminRescueWorkspaces([]); setAdminRescueWorkspaces([]);
setAdminDailyEducation([]);
setAdminEducationQuestions([]);
setDailyEducationForm(emptyDailyEducationForm());
setEducationQuestionForm(emptyDailyEducationQuestion());
setEditingEducationQuestionId('');
setTodayEducation(null);
setEducationOptOut(false);
setEducationAnswers({});
setDailyEducationOpen(false);
setBirds([]); setBirds([]);
setMemorializedBirds([]); setMemorializedBirds([]);
setWeights([]); setWeights([]);
@@ -2274,27 +2210,20 @@ function App() {
const loadAdminDashboard = async () => { const loadAdminDashboard = async () => {
try { try {
const [summaryResponse, rescuesResponse, educationResponse, educationQuestionsResponse] = await Promise.all([ const [summaryResponse, rescuesResponse] = await Promise.all([
apiFetch('/admin/summary', authToken), apiFetch('/admin/summary', authToken),
apiFetch('/admin/rescue-workspaces', authToken), apiFetch('/admin/rescue-workspaces', authToken),
apiFetch('/admin/daily-education', authToken),
apiFetch('/admin/education-questions', authToken),
]); ]);
if (!summaryResponse.ok || !rescuesResponse.ok || !educationResponse.ok || !educationQuestionsResponse.ok) { if (!summaryResponse.ok || !rescuesResponse.ok) {
throw new Error('Unable to load admin dashboard.'); throw new Error('Unable to load admin dashboard.');
} }
const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {}; const summaryData = (await readJsonSafely<{ summary?: AdminSummary }>(summaryResponse)) ?? {};
const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {}; const rescuesData = (await readJsonSafely<{ rescueWorkspaces?: AdminRescueWorkspace[] }>(rescuesResponse)) ?? {};
const educationData = (await readJsonSafely<{ education?: DailyEducation[] }>(educationResponse)) ?? {};
const educationQuestionsData =
(await readJsonSafely<{ questions?: DailyEducationQuestion[] }>(educationQuestionsResponse)) ?? {};
setAdminSummary(summaryData.summary ?? null); setAdminSummary(summaryData.summary ?? null);
setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []); setAdminRescueWorkspaces(rescuesData.rescueWorkspaces ?? []);
setAdminDailyEducation(educationData.education ?? []);
setAdminEducationQuestions(educationQuestionsData.questions ?? []);
} catch (adminError) { } catch (adminError) {
setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.'); setError(adminError instanceof Error ? adminError.message : 'Unable to load admin dashboard.');
} }
@@ -2303,34 +2232,6 @@ function App() {
void loadAdminDashboard(); void loadAdminDashboard();
}, [activePage, authSession?.isAdmin, authToken]); }, [activePage, authSession?.isAdmin, authToken]);
useEffect(() => {
if (!authToken || !authSession) {
return;
}
const loadTodayEducation = async () => {
try {
const response = await apiFetch('/education/today', authToken);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to load daily education.'));
}
const data =
(await readJsonSafely<{ education?: DailyEducation | null; educationOptOut?: boolean }>(response)) ?? {};
setEducationOptOut(Boolean(data.educationOptOut));
setTodayEducation(data.education ?? null);
setEducationAnswers({});
setDailyEducationOpen(false);
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to load daily education.');
}
};
void loadTodayEducation();
}, [authSession, authToken]);
useEffect(() => { useEffect(() => {
if (!selectedBird?.id) { if (!selectedBird?.id) {
setWeights([]); setWeights([]);
@@ -2749,221 +2650,6 @@ function App() {
} }
}; };
const loadDailyEducationIntoForm = (education: DailyEducation) => {
setDailyEducationForm({
publishDate: education.publishDate,
fact: education.fact,
});
};
const loadEducationQuestionIntoForm = (question: DailyEducationQuestion) => {
setEditingEducationQuestionId(question.id);
setEducationQuestionForm({
prompt: question.prompt,
options: [
question.options[0] ?? '',
question.options[1] ?? '',
question.options[2] ?? '',
question.options[3] ?? '',
],
correctAnswerIndex: question.correctAnswerIndex,
explanation: question.explanation ?? '',
});
};
const resetEducationQuestionForm = () => {
setEditingEducationQuestionId('');
setEducationQuestionForm(emptyDailyEducationQuestion());
};
const handleDailyEducationSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!authToken) {
return;
}
setError('');
setSavingDailyEducation(true);
try {
const response = await apiFetch('/admin/daily-education', authToken, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
publishDate: dailyEducationForm.publishDate,
fact: dailyEducationForm.fact.trim(),
}),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save daily education.'));
}
const data = (await readJsonSafely<{ education?: DailyEducation }>(response)) ?? {};
if (!data.education) {
throw new Error('Unable to save daily education.');
}
setAdminDailyEducation((current) =>
[data.education!, ...current.filter((education) => education.id !== data.education!.id && education.publishDate !== data.education!.publishDate)]
.sort((left, right) => right.publishDate.localeCompare(left.publishDate)),
);
if (data.education.publishDate === new Date().toISOString().slice(0, 10) && !educationOptOut) {
const todayResponse = await apiFetch('/education/today', authToken);
const todayData = todayResponse.ok
? await readJsonSafely<{ education?: DailyEducation | null }>(todayResponse)
: null;
setTodayEducation(todayData?.education ?? null);
setEducationAnswers({});
}
setDailyEducationForm(emptyDailyEducationForm());
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to save daily education.');
} finally {
setSavingDailyEducation(false);
}
};
const handleEducationQuestionSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!authToken) {
return;
}
setError('');
setSavingEducationQuestion(true);
try {
const response = await apiFetch(
editingEducationQuestionId ? `/admin/education-questions/${editingEducationQuestionId}` : '/admin/education-questions',
authToken,
{
method: editingEducationQuestionId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: educationQuestionForm.prompt.trim(),
options: educationQuestionForm.options.map((option) => option.trim()),
correctAnswerIndex: educationQuestionForm.correctAnswerIndex,
explanation: educationQuestionForm.explanation.trim(),
}),
},
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save education question.'));
}
const data = (await readJsonSafely<{ question?: DailyEducationQuestion }>(response)) ?? {};
if (!data.question) {
throw new Error('Unable to save education question.');
}
setAdminEducationQuestions((current) => [
data.question!,
...current.filter((question) => question.id !== data.question!.id),
]);
resetEducationQuestionForm();
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to save education question.');
} finally {
setSavingEducationQuestion(false);
}
};
const handleDeleteEducationQuestion = async (questionId: string) => {
if (!authToken) {
return;
}
setError('');
setDeletingEducationQuestionId(questionId);
try {
const response = await apiFetch(`/admin/education-questions/${questionId}`, authToken, { method: 'DELETE' });
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to delete education question.'));
}
setAdminEducationQuestions((current) => current.filter((question) => question.id !== questionId));
if (editingEducationQuestionId === questionId) {
resetEducationQuestionForm();
}
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to delete education question.');
} finally {
setDeletingEducationQuestionId('');
}
};
const handleDeleteDailyEducation = async (educationId: string) => {
if (!authToken) {
return;
}
setError('');
setDeletingDailyEducationId(educationId);
try {
const response = await apiFetch(`/admin/daily-education/${educationId}`, authToken, { method: 'DELETE' });
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to delete daily education.'));
}
setAdminDailyEducation((current) => current.filter((education) => education.id !== educationId));
if (todayEducation?.id === educationId) {
setTodayEducation(null);
}
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to delete daily education.');
} finally {
setDeletingDailyEducationId('');
}
};
const handleEducationPreferenceChange = async (nextEducationOptOut: boolean) => {
if (!authToken) {
return;
}
setError('');
setSavingEducationPreference(true);
try {
const response = await apiFetch('/education/preferences', authToken, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ educationOptOut: nextEducationOptOut }),
});
if (!response.ok) {
throw new Error(await readErrorMessage(response, 'Unable to save education preference.'));
}
setEducationOptOut(nextEducationOptOut);
if (nextEducationOptOut) {
setTodayEducation(null);
setDailyEducationOpen(false);
} else {
const todayResponse = await apiFetch('/education/today', authToken);
const data = todayResponse.ok
? await readJsonSafely<{ education?: DailyEducation | null }>(todayResponse)
: null;
setTodayEducation(data?.education ?? null);
}
setEducationAnswers({});
} catch (educationError) {
setError(educationError instanceof Error ? educationError.message : 'Unable to save education preference.');
} finally {
setSavingEducationPreference(false);
}
};
const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => { const handleCreateWorkspace = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
@@ -4826,74 +4512,6 @@ function App() {
</div> </div>
</section> </section>
{todayEducation ? (
<article className={dailyEducationOpen ? 'panel daily-education-panel open' : 'panel daily-education-panel condensed'}>
<div className="panel-header">
<div>
<p className="eyebrow">Fid fact of the day</p>
<h2>{dailyEducationOpen ? formatDate(todayEducation.publishDate) : 'Daily learning'}</h2>
</div>
<button className="secondary-button" onClick={() => setDailyEducationOpen((current) => !current)} type="button">
{dailyEducationOpen ? 'Close' : 'Open'}
</button>
</div>
{dailyEducationOpen ? (
<>
<p className="daily-fact">{todayEducation.fact}</p>
<section className="daily-quiz" aria-label="Daily education quiz">
{todayEducation.quizQuestions.map((question, questionIndex) => {
const selectedAnswer = educationAnswers[questionIndex];
const hasAnswer = selectedAnswer !== undefined;
return (
<fieldset key={`${todayEducation.id}-${question.id}`} className="quiz-question">
<legend>{question.prompt}</legend>
<div className="quiz-options">
{question.options.map((option, optionIndex) => (
<label
key={`${question.id}-${optionIndex}`}
className={
hasAnswer && optionIndex === question.correctAnswerIndex
? 'quiz-option correct'
: hasAnswer && optionIndex === selectedAnswer
? 'quiz-option incorrect'
: 'quiz-option'
}
>
<input
type="radio"
name={`daily-question-${question.id}`}
checked={selectedAnswer === optionIndex}
onChange={() =>
setEducationAnswers((current) => ({
...current,
[questionIndex]: optionIndex,
}))
}
/>
<span>{option}</span>
</label>
))}
</div>
{hasAnswer ? (
<p className={selectedAnswer === question.correctAnswerIndex ? 'quiz-feedback correct' : 'quiz-feedback'}>
{selectedAnswer === question.correctAnswerIndex ? 'Correct.' : 'Correct answer shown.'}{' '}
{question.explanation}
</p>
) : null}
</fieldset>
);
})}
</section>
</>
) : (
<p className="muted daily-education-teaser">
Open today&apos;s fact and {todayEducation.quizQuestions.length || 'the'} quiz questions.
</p>
)}
</article>
) : null}
<section className="forms-grid"> <section className="forms-grid">
<article className="panel form-panel pulse-panel"> <article className="panel form-panel pulse-panel">
<div className="panel-header"> <div className="panel-header">
@@ -5044,168 +4662,6 @@ function App() {
)} )}
</div> </div>
</article> </article>
<article className="panel admin-education-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Education</p>
<h2>Fid facts</h2>
</div>
<button className="secondary-button" onClick={() => setDailyEducationForm(emptyDailyEducationForm())} type="button">
New date
</button>
</div>
<form className="form-panel" onSubmit={handleDailyEducationSubmit}>
<div className="education-admin-basics">
<label>
Publish date
<input
type="date"
value={dailyEducationForm.publishDate}
onChange={(event) => setDailyEducationForm({ ...dailyEducationForm, publishDate: event.target.value })}
required
/>
</label>
<label>
Fid fact
<textarea
value={dailyEducationForm.fact}
onChange={(event) => setDailyEducationForm({ ...dailyEducationForm, fact: event.target.value })}
rows={3}
required
/>
</label>
</div>
<button className="primary-button" type="submit" disabled={savingDailyEducation}>
{savingDailyEducation ? 'Saving...' : 'Save fact'}
</button>
</form>
<div className="recent-list education-admin-list">
{adminDailyEducation.length ? (
adminDailyEducation.map((education) => (
<article key={education.id} className="vet-visit-card">
<strong>{formatDate(education.publishDate)}</strong>
<span>{education.fact}</span>
<div className="button-row">
<button className="secondary-button" type="button" onClick={() => loadDailyEducationIntoForm(education)}>
Edit
</button>
<button
className="secondary-button"
type="button"
onClick={() => handleDeleteDailyEducation(education.id)}
disabled={deletingDailyEducationId === education.id}
>
{deletingDailyEducationId === education.id ? 'Deleting...' : 'Delete'}
</button>
</div>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No scheduled facts yet</strong>
<small>Add a dated Fid fact for the overview page.</small>
</article>
)}
</div>
</article>
<article className="panel admin-education-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Education</p>
<h2>Quiz question bank</h2>
</div>
<button className="secondary-button" onClick={resetEducationQuestionForm} type="button">
New question
</button>
</div>
<p className="muted">Each day the quiz uses a stable random selection of four saved questions.</p>
<form className="form-panel" onSubmit={handleEducationQuestionSubmit}>
<fieldset className="settings-nested-card quiz-editor-question">
<legend>{editingEducationQuestionId ? 'Edit question' : 'Add question'}</legend>
<label>
Prompt
<input
value={educationQuestionForm.prompt}
onChange={(event) => setEducationQuestionForm({ ...educationQuestionForm, prompt: event.target.value })}
required
/>
</label>
<div className="quiz-editor-options">
{educationQuestionForm.options.map((option, optionIndex) => (
<label key={`bank-option-${optionIndex}`}>
Option {optionIndex + 1}
<input
value={option}
onChange={(event) => {
const options = [...educationQuestionForm.options] as DailyEducationQuestionFormState['options'];
options[optionIndex] = event.target.value;
setEducationQuestionForm({ ...educationQuestionForm, options });
}}
required
/>
</label>
))}
</div>
<label>
Correct option
<select
value={educationQuestionForm.correctAnswerIndex}
onChange={(event) =>
setEducationQuestionForm({ ...educationQuestionForm, correctAnswerIndex: Number(event.target.value) })
}
>
{educationQuestionForm.options.map((_, optionIndex) => (
<option key={`bank-correct-${optionIndex}`} value={optionIndex}>
Option {optionIndex + 1}
</option>
))}
</select>
</label>
<label>
Explanation
<textarea
value={educationQuestionForm.explanation}
onChange={(event) => setEducationQuestionForm({ ...educationQuestionForm, explanation: event.target.value })}
rows={2}
/>
</label>
</fieldset>
<button className="primary-button" type="submit" disabled={savingEducationQuestion}>
{savingEducationQuestion ? 'Saving...' : editingEducationQuestionId ? 'Save question changes' : 'Add question'}
</button>
</form>
<div className="recent-list education-admin-list">
{adminEducationQuestions.length ? (
adminEducationQuestions.map((question) => (
<article key={question.id} className="vet-visit-card">
<strong>{question.prompt}</strong>
<span>Answer: {question.options[question.correctAnswerIndex]}</span>
<small>{question.options.length} options</small>
<div className="button-row">
<button className="secondary-button" type="button" onClick={() => loadEducationQuestionIntoForm(question)}>
Edit
</button>
<button
className="secondary-button"
type="button"
onClick={() => handleDeleteEducationQuestion(question.id)}
disabled={deletingEducationQuestionId === question.id}
>
{deletingEducationQuestionId === question.id ? 'Deleting...' : 'Delete'}
</button>
</div>
</article>
))
) : (
<article className="vet-visit-card empty-card">
<strong>No quiz questions yet</strong>
<small>Add at least four questions before the daily quiz can use a full set.</small>
</article>
)}
</div>
</article>
</section> </section>
) : null} ) : null}
@@ -6701,27 +6157,6 @@ function App() {
</div> </div>
<div className="settings-column settings-column-right"> <div className="settings-column settings-column-right">
<article className="panel form-panel">
<div className="panel-header">
<div>
<p className="eyebrow">Education</p>
<h2>Daily learning</h2>
</div>
</div>
<label className="toggle-card">
<input
type="checkbox"
checked={!educationOptOut}
onChange={(event) => handleEducationPreferenceChange(!event.target.checked)}
disabled={savingEducationPreference}
/>
<span>
<strong>Show daily education</strong>
<small>Display the Fid fact of the day and four-question quiz on Overview.</small>
</span>
</label>
</article>
<article className="panel form-panel settings-card-collaborators"> <article className="panel form-panel settings-card-collaborators">
<div className="panel-header"> <div className="panel-header">
<div> <div>
-122
View File
@@ -745,123 +745,6 @@ textarea {
align-content: start; align-content: start;
} }
.daily-education-panel,
.daily-quiz,
.quiz-options,
.education-question-editor {
display: grid;
gap: 1rem;
}
.daily-education-panel.condensed {
gap: 0.35rem;
padding-block: 1rem;
}
.daily-education-panel.condensed .panel-header {
margin-bottom: 0;
}
.daily-education-teaser {
margin: 0;
}
.daily-fact {
margin: 0;
padding: 1rem;
border-left: 4px solid var(--accent-gold);
border-radius: 0 8px 8px 0;
background: rgba(255, 254, 250, 0.7);
font-size: 1.08rem;
}
.daily-quiz {
grid-template-columns: repeat(auto-fit, minmax(min(290px, 100%), 1fr));
}
.quiz-question,
.quiz-editor-question {
min-width: 0;
margin: 0;
border: 1px solid var(--button-border);
}
.quiz-question {
display: grid;
gap: 0.85rem;
padding: 1rem;
border-radius: 8px;
background: rgba(255, 254, 250, 0.64);
}
.quiz-question legend,
.quiz-editor-question legend {
padding: 0 0.35rem;
font-weight: 700;
}
.quiz-option {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 0.65rem;
min-width: 0;
padding: 0.7rem;
border: 1px solid rgba(39, 105, 179, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.58);
}
.quiz-option.correct {
border-color: rgba(35, 138, 90, 0.42);
background: rgba(223, 247, 229, 0.82);
}
.quiz-option.incorrect {
border-color: rgba(203, 58, 53, 0.36);
background: rgba(255, 236, 232, 0.82);
}
.quiz-option input {
width: auto;
margin: 0.25rem 0 0;
}
.quiz-feedback {
margin: 0;
color: var(--accent-red);
}
.quiz-feedback.correct {
color: var(--accent-green);
}
.admin-education-panel,
.education-admin-basics,
.quiz-editor-question,
.education-admin-list {
display: grid;
gap: 1rem;
}
.education-admin-basics {
grid-template-columns: minmax(180px, 0.35fr) minmax(0, 1fr);
}
.quiz-editor-question {
padding: 1rem;
}
.quiz-editor-options {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.8rem;
}
.education-admin-list span {
overflow-wrap: anywhere;
}
.button-row { .button-row {
display: flex; display: flex;
gap: 0.75rem; gap: 0.75rem;
@@ -1966,11 +1849,6 @@ label {
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.education-admin-basics,
.quiz-editor-options {
grid-template-columns: 1fr;
}
.app-shell, .app-shell,
.auth-panel, .auth-panel,
.hero-card, .hero-card,