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,
} 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,
@@ -112,8 +99,6 @@ import type {
AuthContext,
BillingInterval,
BillingPlan,
DailyEducationRow,
EducationQuestionRow,
BirdGender,
BirdMilestoneReminderCandidateRow,
BirdRow,
@@ -339,27 +324,6 @@ 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;
@@ -554,25 +518,6 @@ 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 '';
@@ -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) => {
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
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',
@@ -142,37 +139,6 @@ 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,
@@ -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;
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;