Files
memind/learn-assistant-routes.mjs
T
john 348e192800 feat(learn): add family learning assistant API routes and public pages
Wire /learn static delivery, parent/child portal routes, and Tang page build scripts for 103 deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 17:09:52 +08:00

295 lines
10 KiB
JavaScript

import path from 'node:path';
import { createLearnAssistantFamilyRegistry } from './learn-assistant-family.mjs';
import { createLearnAssistantService } from './learn-assistant-service.mjs';
function handleError(res, error) {
const status = error?.statusCode ?? 500;
const message = error instanceof Error ? error.message : '请求失败';
if (status >= 500) console.error('[LearnAssistant]', message);
return res.status(status).json({ error: message });
}
async function runHandler(res, work) {
try {
const body = await work;
return res.json(body);
} catch (error) {
return handleError(res, error);
}
}
function readLegacyPassword(req) {
return req.get('x-parent-pw') || req.get('x-admin-pw') || '';
}
function currentUser(req) {
return req.currentUser ?? null;
}
export function createLearnAssistantRuntime(options = {}) {
const dataRoot = options.dataRoot ?? path.join(options.rootDir ?? process.cwd(), 'data', 'learn-assistant');
const service = options.service ?? createLearnAssistantService({ dataRoot });
const familyRegistry = options.familyRegistry
?? createLearnAssistantFamilyRegistry({ dataRoot, seedFamilyDefaults: service.seedFamilyDefaults });
return { dataRoot, familyRegistry, service };
}
export function attachLearnAssistantPublicRoutes(api, options = {}) {
const { familyRegistry, service } = createLearnAssistantRuntime(options);
const prefix = options.routePrefix ?? '/learn';
async function withSlug(req, res, handler) {
try {
const family = await familyRegistry.getFamilyBySlug(req.params.slug);
return runHandler(res, handler(family));
} catch (error) {
return handleError(res, error);
}
}
api.get(`${prefix}/public/:slug/children`, (req, res) =>
withSlug(req, res, (family) => service.listChildren(family.id)));
api.get(`${prefix}/public/:slug/today`, (req, res) =>
withSlug(req, res, (family) =>
service.getToday(family.id, req.query.child, req.query.date)));
api.get(`${prefix}/public/:slug/day`, (req, res) =>
withSlug(req, res, (family) =>
service.getDay(family.id, req.query.child, req.query.date)));
api.post(`${prefix}/public/:slug/checkin`, (req, res) =>
withSlug(req, res, (family) =>
service.checkin(family.id, req.body?.id, req.body)));
api.get(`${prefix}/public/:slug/calendar`, (req, res) =>
withSlug(req, res, (family) =>
service.getCalendar(family.id, req.query.child, req.query.month)));
api.get(`${prefix}/public/:slug/growth`, (req, res) =>
withSlug(req, res, (family) =>
service.getGrowth(family.id, req.query.child)));
api.get(`${prefix}/public/:slug/history`, (req, res) =>
withSlug(req, res, (family) =>
service.getHistory(family.id, req.query.child)));
// legacy slug routes (tang 等旧链接)
api.get(`${prefix}/:slug/children`, (req, res) =>
withSlug(req, res, (family) => service.listChildren(family.id)));
api.get(`${prefix}/:slug/today`, (req, res) =>
withSlug(req, res, (family) =>
service.getToday(family.id, req.query.child, req.query.date)));
api.get(`${prefix}/:slug/day`, (req, res) =>
withSlug(req, res, (family) =>
service.getDay(family.id, req.query.child, req.query.date)));
api.post(`${prefix}/:slug/checkin`, (req, res) =>
withSlug(req, res, (family) =>
service.checkin(family.id, req.body?.id, req.body)));
api.get(`${prefix}/:slug/calendar`, (req, res) =>
withSlug(req, res, (family) =>
service.getCalendar(family.id, req.query.child, req.query.month)));
api.get(`${prefix}/:slug/growth`, (req, res) =>
withSlug(req, res, (family) =>
service.getGrowth(family.id, req.query.child)));
api.get(`${prefix}/:slug/history`, (req, res) =>
withSlug(req, res, (family) =>
service.getHistory(family.id, req.query.child)));
// legacy password parent API (tang 旧家长端)
async function withLegacyParent(req, res, handler) {
try {
const family = await familyRegistry.getFamilyBySlug(req.params.slug);
await service.assertLegacyPassword(family.id, readLegacyPassword(req));
return runHandler(res, handler(family));
} catch (error) {
return handleError(res, error);
}
}
api.get(`${prefix}/:slug/parent/stats`, (req, res) =>
withLegacyParent(req, res, (family) => service.parentStats(family.id)));
api.get(`${prefix}/:slug/parent/templates`, (req, res) =>
withLegacyParent(req, res, (family) => service.parentListTemplates(family.id)));
api.post(`${prefix}/:slug/parent/templates`, (req, res) =>
withLegacyParent(req, res, (family) =>
service.parentSaveTemplate(family.id, req.body)));
api.put(`${prefix}/:slug/parent/templates/:id`, (req, res) =>
withLegacyParent(req, res, (family) =>
service.parentSaveTemplate(family.id, req.body, +req.params.id)));
api.delete(`${prefix}/:slug/parent/templates/:id`, (req, res) =>
withLegacyParent(req, res, (family) =>
service.parentDeleteTemplate(family.id, +req.params.id)));
api.get(`${prefix}/:slug/parent/categories`, (req, res) =>
withLegacyParent(req, res, (family) => service.parentListCategories(family.id)));
api.post(`${prefix}/:slug/parent/categories`, (req, res) =>
withLegacyParent(req, res, (family) =>
service.parentAddCategory(family.id, req.body)));
api.delete(`${prefix}/:slug/parent/categories/:id`, (req, res) =>
withLegacyParent(req, res, (family) =>
service.parentDeleteCategory(family.id, +req.params.id)));
api.get(`${prefix}/:slug/parent/children`, (req, res) =>
withLegacyParent(req, res, (family) => service.parentListChildren(family.id)));
api.post(`${prefix}/:slug/parent/children`, (req, res) =>
withLegacyParent(req, res, (family) =>
service.parentAddChild(family.id, req.body)));
api.delete(`${prefix}/:slug/parent/children/:id`, (req, res) =>
withLegacyParent(req, res, (family) =>
service.parentDeleteChild(family.id, +req.params.id)));
api.post(`${prefix}/:slug/parent/password`, (req, res) =>
withLegacyParent(req, res, (family) =>
service.parentChangePassword(family.id, req.body)));
return { familyRegistry, service };
}
export function attachLearnAssistantParentRoutes(api, options = {}) {
const { familyRegistry, service } = createLearnAssistantRuntime(options);
const prefix = options.routePrefix ?? '/learn';
function requireUser(req, res) {
const user = currentUser(req);
if (!user?.id) {
res.status(401).json({ error: '请先登录 Memind' });
return null;
}
return user;
}
async function withFamily(req, res, handler) {
const user = requireUser(req, res);
if (!user) return null;
try {
const family = await familyRegistry.requireFamilyForUser(user.id, {
displayName: user.displayName ?? user.display_name ?? user.username,
});
return runHandler(res, handler(family, user));
} catch (error) {
return handleError(res, error);
}
}
api.get(`${prefix}/me/family`, async (req, res) => {
const user = requireUser(req, res);
if (!user) return;
try {
const { family, created } = await familyRegistry.getFamilyForUser(user.id, {
displayName: user.displayName ?? user.display_name ?? user.username,
});
const urls = familyRegistry.familyUrls(family);
return res.json({
family: {
id: family.id,
slug: family.slug,
name: family.name,
members: family.members,
},
role: family.members.find((m) => m.userId === user.id)?.role ?? 'parent',
urls,
created,
});
} catch (error) {
return handleError(res, error);
}
});
api.post(`${prefix}/me/invite`, (req, res) =>
withFamily(req, res, async (family, user) => {
const invite = await familyRegistry.createInvite(user.id, {
displayName: user.displayName ?? user.display_name ?? user.username,
});
const urls = familyRegistry.familyUrls(family);
return {
...invite,
joinUrl: `${urls.joinPath}${invite.token}`,
};
}));
api.post(`${prefix}/me/join`, async (req, res) => {
const user = requireUser(req, res);
if (!user) return;
try {
const result = await familyRegistry.acceptInvite(user.id, req.body?.token, {
displayName: user.displayName ?? user.display_name ?? user.username,
});
return res.json({
ok: true,
family: {
id: result.family.id,
slug: result.family.slug,
name: result.family.name,
members: result.family.members,
},
});
} catch (error) {
return handleError(res, error);
}
});
api.get(`${prefix}/me/parent/stats`, (req, res) =>
withFamily(req, res, (family) => service.parentStats(family.id)));
api.get(`${prefix}/me/parent/templates`, (req, res) =>
withFamily(req, res, (family) => service.parentListTemplates(family.id)));
api.post(`${prefix}/me/parent/templates`, (req, res) =>
withFamily(req, res, (family) =>
service.parentSaveTemplate(family.id, req.body)));
api.put(`${prefix}/me/parent/templates/:id`, (req, res) =>
withFamily(req, res, (family) =>
service.parentSaveTemplate(family.id, req.body, +req.params.id)));
api.delete(`${prefix}/me/parent/templates/:id`, (req, res) =>
withFamily(req, res, (family) =>
service.parentDeleteTemplate(family.id, +req.params.id)));
api.get(`${prefix}/me/parent/categories`, (req, res) =>
withFamily(req, res, (family) => service.parentListCategories(family.id)));
api.post(`${prefix}/me/parent/categories`, (req, res) =>
withFamily(req, res, (family) =>
service.parentAddCategory(family.id, req.body)));
api.delete(`${prefix}/me/parent/categories/:id`, (req, res) =>
withFamily(req, res, (family) =>
service.parentDeleteCategory(family.id, +req.params.id)));
api.get(`${prefix}/me/parent/children`, (req, res) =>
withFamily(req, res, (family) => service.parentListChildren(family.id)));
api.post(`${prefix}/me/parent/children`, (req, res) =>
withFamily(req, res, (family) =>
service.parentAddChild(family.id, req.body)));
api.delete(`${prefix}/me/parent/children/:id`, (req, res) =>
withFamily(req, res, (family) =>
service.parentDeleteChild(family.id, +req.params.id)));
return { familyRegistry, service };
}
/** @deprecated use attachLearnAssistantPublicRoutes + attachLearnAssistantParentRoutes */
export function attachLearnAssistantRoutes(api, options = {}) {
const runtime = attachLearnAssistantPublicRoutes(api, options);
attachLearnAssistantParentRoutes(api, options);
return runtime;
}