Compare commits
42
Commits
dev
...
3ba6410972
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ba6410972 | ||
|
|
4a43a450f3 | ||
|
|
46605d8717 | ||
|
|
8f1144de1a | ||
|
|
53b75588a2 | ||
|
|
1849ecd73b | ||
|
|
53b7d34520 | ||
|
|
f65a4bed24 | ||
|
|
cc4a2382c6 | ||
|
|
5735bb7735 | ||
|
|
88ff06237e | ||
|
|
fbb13561b0 | ||
|
|
b15861c856 | ||
|
|
2aeaa119f7 | ||
|
|
36690c0174 | ||
|
|
b76ad35c07 | ||
|
|
6918b55a58 | ||
|
|
49f1713e26 | ||
|
|
c9fa7e4246 | ||
|
|
0411ec5175 | ||
|
|
7b7171c109 | ||
|
|
c02bb4d6d8 | ||
|
|
603b4eee4d | ||
|
|
52008f5b43 | ||
|
|
5b57cdd6bf | ||
|
|
60eadf0847 | ||
|
|
682ccfd41f | ||
|
|
59c6b19ad6 | ||
|
|
aa1a4cf6ff | ||
|
|
5f0fad3cbb | ||
|
|
545fae59b2 | ||
|
|
d748d2db21 | ||
|
|
095c91e56d | ||
|
|
f2017068d5 | ||
|
|
c9702495a3 | ||
|
|
e965cb55ef | ||
|
|
505a9b8496 | ||
|
|
c6dc5b22b8 | ||
|
|
f16e88e2f0 | ||
|
|
016bc187d4 | ||
|
|
104f01f75d | ||
|
|
568aee3e70 |
@@ -3,13 +3,14 @@ name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy-dev:
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
if: ${{ github.event_name == 'push' && (github.ref_name == 'dev' || github.ref_name == 'develop') }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
volumes:
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
docker compose -f docker-compose.dev.yaml up -d --build
|
||||
|
||||
deploy-prod:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'push' && github.ref_name == 'main') }}
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
volumes:
|
||||
|
||||
@@ -47,6 +47,23 @@ The default `docker-compose.yml` is development-only. It mounts source files, in
|
||||
|
||||
## Operations
|
||||
|
||||
### Health checks
|
||||
|
||||
Monitor these production checks:
|
||||
|
||||
- Frontend: `GET https://your-host/healthz`
|
||||
- Verifies Nginx is serving the frontend container.
|
||||
- Backend liveness: `GET https://your-host/api/health/live`
|
||||
- Verifies the API process is running.
|
||||
- Backend readiness: `GET https://your-host/api/health/ready`
|
||||
- Verifies the API can reach Postgres and Redis. Returns `503` if either dependency is unavailable.
|
||||
- Backend metrics: `GET https://your-host/api/metrics`
|
||||
- Admin-authenticated process, request, and queue metrics.
|
||||
- Postgres and Redis:
|
||||
- Use the Docker health checks in `docker-compose.prod.yml`.
|
||||
- Worker:
|
||||
- Use the Docker health check in `docker-compose.prod.yml`; it validates worker dependencies. The worker does not expose HTTP.
|
||||
|
||||
### Backups
|
||||
|
||||
Create a compressed Postgres backup from the Docker Compose Postgres service:
|
||||
|
||||
@@ -78,19 +78,6 @@ import {
|
||||
listAuditLogEntries,
|
||||
listFlockNotes,
|
||||
} from './repositories/auditRepository.js';
|
||||
import {
|
||||
deleteDailyEducation,
|
||||
deleteEducationQuestion,
|
||||
createEducationQuestion,
|
||||
getDailyEducationForDate,
|
||||
getEducationOptOut,
|
||||
listDailyEducationForAdmin,
|
||||
listDailyEducationQuestions,
|
||||
listEducationQuestionsForAdmin,
|
||||
updateEducationOptOut,
|
||||
updateEducationQuestion,
|
||||
upsertDailyEducation,
|
||||
} from './repositories/educationRepository.js';
|
||||
import {
|
||||
buildBirdPhotoObjectKey,
|
||||
getImageExtensionFromContentType,
|
||||
@@ -130,8 +117,6 @@ import type {
|
||||
AuditLogEntryRow,
|
||||
BillingInterval,
|
||||
BillingPlan,
|
||||
DailyEducationRow,
|
||||
EducationQuestionRow,
|
||||
BirdGender,
|
||||
BirdMilestoneReminderCandidateRow,
|
||||
BirdRow,
|
||||
@@ -537,27 +522,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;
|
||||
@@ -752,25 +716,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 '';
|
||||
@@ -3014,149 +2959,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,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { db } from './db/client.js';
|
||||
import { closeBirdMilestoneReminderQueue, getBirdMilestoneReminderQueueCounts } from './queues/birdMilestoneReminderQueue.js';
|
||||
|
||||
const timeoutMs = Number(process.env.HEALTHCHECK_TIMEOUT_MS ?? 5_000);
|
||||
|
||||
const withTimeout = async <T>(operation: Promise<T>, label: string): Promise<T> => {
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => reject(new Error(`${label} timed out`)), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkHttp = async (path: string) => {
|
||||
const port = process.env.PORT ?? '5000';
|
||||
const response = await withTimeout(fetch(`http://127.0.0.1:${port}${path}`), path);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`${path} returned ${response.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
const checkWorkerDependencies = async () => {
|
||||
await withTimeout(db.query('SELECT 1'), 'postgres');
|
||||
await withTimeout(getBirdMilestoneReminderQueueCounts(), 'redis');
|
||||
};
|
||||
|
||||
const mode = process.argv[2] ?? 'api-ready';
|
||||
|
||||
try {
|
||||
if (mode === 'api-live') {
|
||||
await checkHttp('/api/health/live');
|
||||
} else if (mode === 'api-ready') {
|
||||
await checkHttp('/api/health/ready');
|
||||
} else if (mode === 'worker') {
|
||||
await checkWorkerDependencies();
|
||||
} else {
|
||||
throw new Error(`Unknown healthcheck mode: ${mode}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await Promise.allSettled([closeBirdMilestoneReminderQueue(), db.close()]);
|
||||
}
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
createBird,
|
||||
createPendingBirdTransfer,
|
||||
getBirdById,
|
||||
getOpenBirdTransferCode,
|
||||
listWeightsForBird,
|
||||
markBirdTransferCodeCompleted,
|
||||
transferBirdToWorkspace,
|
||||
} from './birdRepository.js';
|
||||
import { mockDb } from '../test/mockDb.js';
|
||||
@@ -253,3 +255,25 @@ test('completePendingBirdTransfersForOwner moves pending birds and marks complet
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
test('getOpenBirdTransferCode only returns unconsumed codes', async () => {
|
||||
const { calls } = mockDb({ rowCount: 0, rows: [] });
|
||||
|
||||
const transferCode = await getOpenBirdTransferCode('ADOPT-123');
|
||||
|
||||
assert.equal(transferCode, null);
|
||||
assert.deepEqual(calls[0].params, ['ADOPT-123']);
|
||||
assert.match(calls[0].text, /bird_transfer_codes\.completed_at IS NULL/);
|
||||
assert.match(calls[0].text, /bird_transfer_codes\.revoked_at IS NULL/);
|
||||
assert.match(calls[0].text, /birds\.workspace_id = bird_transfer_codes\.source_workspace_id/);
|
||||
});
|
||||
|
||||
test('markBirdTransferCodeCompleted consumes a code for the receiving workspace', async () => {
|
||||
const { calls } = mockDb({ rowCount: 1, rows: [] });
|
||||
|
||||
await markBirdTransferCodeCompleted('code-1', 22);
|
||||
|
||||
assert.deepEqual(calls[0].params, ['code-1', 22]);
|
||||
assert.match(calls[0].text, /SET completed_at = CURRENT_TIMESTAMP/);
|
||||
assert.match(calls[0].text, /completed_workspace_id = \$2/);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -97,6 +97,12 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "dist/healthcheck.js", "api-ready"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
@@ -156,6 +162,12 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "dist/healthcheck.js", "worker"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
@@ -167,6 +179,12 @@ services:
|
||||
container_name: flockpal-frontend
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=traefik
|
||||
|
||||
+35
-2
@@ -319,14 +319,47 @@ Validation failures return `400` with this shape:
|
||||
|
||||
#### `GET /api/health`
|
||||
|
||||
Public health check.
|
||||
Public readiness-compatible health check. Verifies backend dependencies.
|
||||
|
||||
Response `200`:
|
||||
|
||||
```json
|
||||
{ "ok": true }
|
||||
{
|
||||
"ok": true,
|
||||
"service": "flockpal-backend",
|
||||
"status": "ready",
|
||||
"checkedAt": "2026-06-06T00:00:00.000Z",
|
||||
"dependencies": {
|
||||
"postgres": { "ok": true, "latencyMs": 3 },
|
||||
"redis": { "ok": true, "latencyMs": 4 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Response `503` when Postgres or Redis is unavailable.
|
||||
|
||||
#### `GET /api/health/live`
|
||||
|
||||
Public liveness check. Verifies the backend process is running without checking dependencies.
|
||||
|
||||
Response `200`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"service": "flockpal-backend",
|
||||
"status": "live",
|
||||
"uptimeSeconds": 120,
|
||||
"checkedAt": "2026-06-06T00:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### `GET /api/health/ready`
|
||||
|
||||
Public readiness check. Verifies the backend can reach Postgres and Redis.
|
||||
|
||||
Response `200` uses the same shape as `GET /api/health`; response `503` means at least one dependency failed.
|
||||
|
||||
### Metrics
|
||||
|
||||
#### `GET /api/metrics`
|
||||
|
||||
@@ -12,6 +12,12 @@ server {
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
location = /healthz {
|
||||
access_log off;
|
||||
add_header Content-Type text/plain;
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
+2
-567
@@ -193,23 +193,6 @@ 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;
|
||||
@@ -409,18 +392,6 @@ 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;
|
||||
};
|
||||
@@ -943,18 +914,6 @@ const emptyFlockNoteForm: FlockNoteFormState = {
|
||||
body: '',
|
||||
};
|
||||
|
||||
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 },
|
||||
@@ -2173,20 +2132,6 @@ function App() {
|
||||
const [birdTimelineEventForm, setBirdTimelineEventForm] = useState<BirdTimelineEventFormState>(emptyBirdTimelineEventForm);
|
||||
const [adminSummary, setAdminSummary] = useState<AdminSummary | null>(null);
|
||||
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 [memorializedBirds, setMemorializedBirds] = useState<Bird[]>([]);
|
||||
const [selectedBirdId, setSelectedBirdId] = useState<string>('');
|
||||
@@ -2797,15 +2742,6 @@ function App() {
|
||||
setAuditLogEntries([]);
|
||||
setAdminSummary(null);
|
||||
setAdminRescueWorkspaces([]);
|
||||
setAdminDailyEducation([]);
|
||||
setAdminEducationQuestions([]);
|
||||
setDailyEducationForm(emptyDailyEducationForm());
|
||||
setEducationQuestionForm(emptyDailyEducationQuestion());
|
||||
setEditingEducationQuestionId('');
|
||||
setTodayEducation(null);
|
||||
setEducationOptOut(false);
|
||||
setEducationAnswers({});
|
||||
setDailyEducationOpen(false);
|
||||
setBirds([]);
|
||||
setMemorializedBirds([]);
|
||||
setWeights([]);
|
||||
@@ -3105,27 +3041,20 @@ function App() {
|
||||
|
||||
const loadAdminDashboard = async () => {
|
||||
try {
|
||||
const [summaryResponse, rescuesResponse, educationResponse, educationQuestionsResponse] = await Promise.all([
|
||||
const [summaryResponse, rescuesResponse] = 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 || !educationResponse.ok || !educationQuestionsResponse.ok) {
|
||||
if (!summaryResponse.ok || !rescuesResponse.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.');
|
||||
}
|
||||
@@ -3134,34 +3063,6 @@ 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([]);
|
||||
@@ -3682,221 +3583,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>) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -6430,74 +6116,6 @@ function App() {
|
||||
</div>
|
||||
</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's fact and {todayEducation.quizQuestions.length || 'the'} quiz questions.
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
) : null}
|
||||
|
||||
<section className="forms-grid">
|
||||
<article className="panel form-panel pulse-panel">
|
||||
<div className="panel-header">
|
||||
@@ -6654,168 +6272,6 @@ function App() {
|
||||
)}
|
||||
</div>
|
||||
</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>
|
||||
) : null}
|
||||
|
||||
@@ -8941,27 +8397,6 @@ function App() {
|
||||
|
||||
</div>
|
||||
<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">
|
||||
<div className="panel-header">
|
||||
<div>
|
||||
|
||||
@@ -743,123 +743,6 @@ textarea {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
@@ -2544,11 +2427,6 @@ label {
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.education-admin-basics,
|
||||
.quiz-editor-options {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.app-shell,
|
||||
.auth-panel,
|
||||
.hero-card,
|
||||
|
||||
Reference in New Issue
Block a user