Revert education feature from main
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user