feat: scaffold supabase multi-tenant backend

This commit is contained in:
Codex
2026-06-21 21:54:43 +08:00
commit c9c767c7bd
99 changed files with 36660 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalStringArray, readJsonBody, stringParam, tenantIdFrom } from '../../core/request.js';
import { query } from '../../core/db.js';
interface QuestionVideoRow {
questionId: string;
videoType: string;
order: number;
id: string;
legacyId: string | null;
title: string;
description: string | null;
videoUrl: string | null;
thumbnailUrl: string | null;
duration: number | null;
knowledgeTags: unknown[];
isGeneral: boolean;
subjectId: string | null;
difficulty: number | null;
}
function videoSelectSql() {
return `
select qv.question_id as "questionId", qv.video_type as "videoType",
qv.sort_order as "order",
v.id, v.legacy_id as "legacyId", v.title, v.description,
v.video_url as "videoUrl", v.thumbnail_url as "thumbnailUrl",
v.duration_seconds as "duration", v.knowledge_tags as "knowledgeTags",
v.is_general as "isGeneral", v.subject_id as "subjectId",
v.difficulty
from public.question_videos qv
join public.video_explanations v on v.id = qv.video_id and v.tenant_id = qv.tenant_id
`;
}
export async function questionVideosRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const questionId = stringParam(ctx, 'questionId');
if (!questionId) {
throw new HttpError(400, 'questionId is required', 'QUESTION_ID_REQUIRED');
}
const videos = await query<QuestionVideoRow>(
`
${videoSelectSql()}
where qv.tenant_id = $1 and qv.question_id = $2 and v.is_active = true
order by qv.sort_order asc, v.sort_order asc, v.created_at asc
`,
[tenantId, questionId],
);
return { videos, total: videos.length };
}
export async function questionVideosBatchRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const questionIds = optionalStringArray(body, 'questionIds').slice(0, 50);
if (!questionIds.length) {
throw new HttpError(400, 'questionIds is required', 'QUESTION_IDS_REQUIRED');
}
const rows = await query<QuestionVideoRow>(
`
${videoSelectSql()}
where qv.tenant_id = $1 and qv.question_id = any($2::uuid[]) and v.is_active = true
order by qv.question_id, qv.sort_order asc, v.sort_order asc
`,
[tenantId, questionIds],
);
const data: Record<string, { hasVideo: boolean; videos: QuestionVideoRow[] }> = {};
for (const row of rows) {
data[row.questionId] ||= { hasVideo: true, videos: [] };
data[row.questionId].videos.push(row);
}
return { data };
}
export async function videoSearchRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const subjectId = stringParam(ctx, 'subjectId');
const tags = stringParam(ctx, 'tags')
.split(',')
.map(tag => tag.trim())
.filter(Boolean);
const limit = intParam(ctx, 'limit', 50, 200);
const videos = await query(
`
select id, legacy_id as "legacyId", title, description,
video_url as "videoUrl", thumbnail_url as "thumbnailUrl",
duration_seconds as "duration", knowledge_tags as "knowledgeTags",
is_general as "isGeneral", subject_id as "subjectId",
difficulty, sort_order as "order", created_at as "createdAt"
from public.video_explanations
where tenant_id = $1
and is_active = true
and is_general = true
and ($2::uuid is null or subject_id = $2::uuid)
and ($3::text[] = '{}'::text[] or knowledge_tags ?| $3::text[])
order by sort_order asc, created_at desc
limit $4
`,
[tenantId, subjectId || null, tags, limit],
);
return { videos, total: videos.length };
}