forked from wangziqi/gongxue-base
feat: add tenant student operations
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
|
||||
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
|
||||
- `apps/api` 独立业务 API,后续供 H5、Taro 小程序、管理后台统一调用;已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口。
|
||||
- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、自定义角色模板、班级/教师/学生范围权限、审计日志。
|
||||
- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、自定义角色模板、班级/教师/学生范围权限、学生批量导入、批量分班、学生备注、跟进任务、审计日志。
|
||||
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目/单词/知识手册 JSON 批量导入。
|
||||
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。
|
||||
- 平台后台能力:租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。
|
||||
@@ -178,4 +178,4 @@ npm run check:refactor
|
||||
2. Taro 前端 scaffold,让 H5 和小程序共用同一套 API。
|
||||
3. 对象存储上传后校验、PDF 预览、防盗链和视频水印。
|
||||
4. Excel/CSV 以及分数线、视频批量导入;把现有 JSON 导入升级为可排队异步执行。
|
||||
5. 学生批量导入/批量分班、微信网页/QQ 登录、退款对账、CRM worker、公共题库授权和租户采纳。
|
||||
5. 微信网页/QQ 登录、退款对账、CRM worker、公共题库授权、租户采纳、题目反馈、签到积分和排行榜。
|
||||
|
||||
@@ -14,7 +14,7 @@ const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
|
||||
tenant_owner: ['*'],
|
||||
tenant_admin: ['*'],
|
||||
tenant_operator: ['content:*', 'marketing:*', 'codes:read', 'coupons:read', 'referral:read', 'crm:read'],
|
||||
teacher: ['content:*', 'classes:read', 'students:read'],
|
||||
teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'],
|
||||
sales: ['codes:*', 'coupons:*', 'referral:*'],
|
||||
agent: ['codes:read', 'coupons:read', 'referral:self'],
|
||||
student: [],
|
||||
@@ -106,6 +106,12 @@ export function tenantPermissionCatalog() {
|
||||
{ key: 'classes:write', label: '班级管理' },
|
||||
{ key: 'students:read', label: '学生查看' },
|
||||
{ key: 'students:write', label: '学生管理' },
|
||||
{ key: 'students:bulk:write', label: '学生批量导入/分班' },
|
||||
{ key: 'students:status:write', label: '学生禁用/恢复' },
|
||||
{ key: 'students:notes:read', label: '学生备注查看' },
|
||||
{ key: 'students:notes:write', label: '学生备注管理' },
|
||||
{ key: 'students:followups:read', label: '学生跟进任务查看' },
|
||||
{ key: 'students:followups:write', label: '学生跟进任务管理' },
|
||||
{ key: 'members:read', label: '成员查看' },
|
||||
{ key: 'members:write', label: '成员管理' },
|
||||
{ key: 'roles:read', label: '角色模板查看' },
|
||||
|
||||
@@ -15,6 +15,13 @@ const CLASS_STATUSES = ['active', 'disabled', 'archived'];
|
||||
const CLASS_MEMBER_TYPES = ['student', 'teacher', 'assistant', 'head_teacher'];
|
||||
const CLASS_MEMBER_STATUSES = ['active', 'disabled', 'removed'];
|
||||
const STUDENT_MEMBER_STATUSES = ['active', 'invited', 'disabled'];
|
||||
const STUDENT_NOTE_TYPES = ['general', 'learning', 'service', 'sales', 'risk', 'follow_up'];
|
||||
const STUDENT_NOTE_VISIBILITIES = ['tenant_staff', 'class_staff', 'author_only'];
|
||||
const STUDENT_FOLLOWUP_TYPES = ['learning', 'service', 'sales', 'renewal', 'risk', 'custom'];
|
||||
const STUDENT_FOLLOWUP_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
||||
const STUDENT_FOLLOWUP_STATUSES = ['open', 'in_progress', 'done', 'cancelled'];
|
||||
const MAX_BULK_STUDENTS = 200;
|
||||
const MAX_BULK_CLASS_ASSIGNMENTS = 500;
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
@@ -33,6 +40,10 @@ function intValue(value: unknown, fallback: number) {
|
||||
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
|
||||
}
|
||||
|
||||
function boolValue(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
|
||||
const candidate = nullableString(value) || fallback;
|
||||
if (!allowed.includes(candidate)) {
|
||||
@@ -57,6 +68,21 @@ function classCodeValue(value: unknown) {
|
||||
return code;
|
||||
}
|
||||
|
||||
function asObjectArray(value: unknown, fieldName: string, maxLength: number) {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new HttpError(400, `${fieldName} must be an array`, 'INVALID_ARRAY_FIELD');
|
||||
}
|
||||
if (value.length > maxLength) {
|
||||
throw new HttpError(413, `${fieldName} supports at most ${maxLength} items`, 'BULK_LIMIT_EXCEEDED');
|
||||
}
|
||||
return value.map((item, index) => {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
throw new HttpError(400, `${fieldName}[${index}] must be an object`, 'INVALID_ARRAY_ITEM');
|
||||
}
|
||||
return item as JsonBody;
|
||||
});
|
||||
}
|
||||
|
||||
function canReadAllClassScope(auth: TenantAdminAuth) {
|
||||
return (
|
||||
auth.role === 'tenant_owner' ||
|
||||
@@ -76,6 +102,7 @@ function maskStudentFields<T extends Record<string, unknown>>(auth: TenantAdminA
|
||||
return {
|
||||
...item,
|
||||
phone: null,
|
||||
studentPhone: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -128,6 +155,62 @@ async function ensureReadableClass(auth: TenantAdminAuth, classId: string) {
|
||||
if (!rows[0]) throw new HttpError(404, 'Class not found', 'CLASS_NOT_FOUND');
|
||||
}
|
||||
|
||||
async function ensureStudentInScope(auth: TenantAdminAuth, studentUserId: string) {
|
||||
const scopedIds = await scopedClassIds(auth);
|
||||
const params: unknown[] = [auth.tenantId, studentUserId];
|
||||
const filters = [
|
||||
'tm.tenant_id = $1',
|
||||
'tm.user_id = $2::uuid',
|
||||
`tm.role = 'student'`,
|
||||
];
|
||||
if (scopedIds) {
|
||||
params.push(scopedIds);
|
||||
filters.push(`exists (
|
||||
select 1 from public.tenant_class_members scoped_cm
|
||||
where scoped_cm.tenant_id = tm.tenant_id
|
||||
and scoped_cm.user_id = tm.user_id
|
||||
and scoped_cm.class_id = any($${params.length}::uuid[])
|
||||
and scoped_cm.member_type = 'student'
|
||||
and scoped_cm.status = 'active'
|
||||
)`);
|
||||
}
|
||||
const rows = await query<{ userId: string }>(
|
||||
`
|
||||
select tm.user_id as "userId"
|
||||
from public.tenant_memberships tm
|
||||
where ${filters.join(' and ')}
|
||||
limit 1
|
||||
`,
|
||||
params,
|
||||
);
|
||||
if (!rows[0]) {
|
||||
throw new HttpError(scopedIds ? 403 : 404, 'Student is outside the current data scope', 'STUDENT_SCOPE_REQUIRED');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureUserTenantMembership(
|
||||
client: pg.PoolClient,
|
||||
tenantId: string,
|
||||
userId: string | null,
|
||||
allowedRoles: string[],
|
||||
errorCode: string,
|
||||
) {
|
||||
if (!userId) return;
|
||||
const result = await client.query<{ id: string }>(
|
||||
`
|
||||
select id
|
||||
from public.tenant_memberships
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and role = any($3::text[])
|
||||
and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, allowedRoles],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(400, 'User is not an active tenant member', errorCode);
|
||||
}
|
||||
|
||||
async function ensureTenantReference(
|
||||
client: pg.PoolClient,
|
||||
tableName: 'regions' | 'schools' | 'majors',
|
||||
@@ -683,6 +766,181 @@ export async function upsertTenantStudentRoute(ctx: RequestContext) {
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function updateTenantStudentStatusRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'students:status:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const userId = requiredString(body, 'userId');
|
||||
const status = optionalChoice(body.status, STUDENT_MEMBER_STATUSES, 'active');
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const result = await client.query(
|
||||
`
|
||||
update public.tenant_memberships
|
||||
set status = $3,
|
||||
updated_at = now()
|
||||
where tenant_id = $1
|
||||
and user_id = $2
|
||||
and role = 'student'
|
||||
returning id as "membershipId", user_id as "userId", role, status, updated_at as "updatedAt"
|
||||
`,
|
||||
[auth.tenantId, userId, status],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Student membership not found', 'STUDENT_NOT_FOUND');
|
||||
await recordAudit(client, auth, 'tenant.student.status_updated', 'tenant_memberships', result.rows[0].membershipId, {
|
||||
userId,
|
||||
status,
|
||||
reason: nullableString(body.reason),
|
||||
});
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function bulkUpsertTenantStudentsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'students:bulk:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const students = asObjectArray(body.students, 'students', MAX_BULK_STUDENTS);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const items: unknown[] = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
for (let index = 0; index < students.length; index += 1) {
|
||||
const student = students[index];
|
||||
try {
|
||||
const userId = await resolveOrCreateUser(client, student, 'student');
|
||||
const status = optionalChoice(student.status, STUDENT_MEMBER_STATUSES, 'active');
|
||||
const regionId = nullableString(student.regionId);
|
||||
const selectedSchoolId = nullableString(student.selectedSchoolId);
|
||||
const selectedMajorId = nullableString(student.selectedMajorId);
|
||||
await ensureTenantReference(client, 'regions', auth.tenantId, regionId, 'REGION_NOT_FOUND');
|
||||
await ensureTenantReference(client, 'schools', auth.tenantId, selectedSchoolId, 'SCHOOL_NOT_FOUND');
|
||||
await ensureTenantReference(client, 'majors', auth.tenantId, selectedMajorId, 'MAJOR_NOT_FOUND');
|
||||
await ensureTenantMembership(client, auth.tenantId, userId, 'student', status);
|
||||
|
||||
const profile = await client.query(
|
||||
`
|
||||
insert into public.student_profiles (
|
||||
tenant_id, user_id, legacy_user_id, region_id, selected_school_id,
|
||||
selected_major_id, stats, progress, module_selections
|
||||
)
|
||||
values ($1, $2, $3, $4::uuid, $5::uuid, $6::uuid, $7::jsonb, $8::jsonb, $9::jsonb)
|
||||
on conflict (tenant_id, user_id)
|
||||
do update set legacy_user_id = coalesce(excluded.legacy_user_id, public.student_profiles.legacy_user_id),
|
||||
region_id = coalesce(excluded.region_id, public.student_profiles.region_id),
|
||||
selected_school_id = coalesce(excluded.selected_school_id, public.student_profiles.selected_school_id),
|
||||
selected_major_id = coalesce(excluded.selected_major_id, public.student_profiles.selected_major_id),
|
||||
stats = case when $7::jsonb = '{}'::jsonb then public.student_profiles.stats else excluded.stats end,
|
||||
progress = case when $8::jsonb = '{}'::jsonb then public.student_profiles.progress else excluded.progress end,
|
||||
module_selections = case when $9::jsonb = '{}'::jsonb then public.student_profiles.module_selections else excluded.module_selections end,
|
||||
updated_at = now()
|
||||
returning id as "profileId", user_id as "userId", region_id as "regionId",
|
||||
selected_school_id as "selectedSchoolId", selected_major_id as "selectedMajorId"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
userId,
|
||||
nullableString(student.legacyUserId),
|
||||
regionId,
|
||||
selectedSchoolId,
|
||||
selectedMajorId,
|
||||
jsonBodyValue(student.stats),
|
||||
jsonBodyValue(student.progress),
|
||||
jsonBodyValue(student.moduleSelections),
|
||||
],
|
||||
);
|
||||
items.push({ index, ...profile.rows[0], status });
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
index,
|
||||
code: error instanceof HttpError ? error.code : 'STUDENT_BULK_ITEM_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Student import item failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(client, auth, 'tenant.students.bulk_upserted', 'student_profiles', null, {
|
||||
total: students.length,
|
||||
successCount: items.length,
|
||||
errorCount: errors.length,
|
||||
});
|
||||
return { total: students.length, successCount: items.length, errorCount: errors.length, items, errors };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function bulkAssignTenantClassMembersRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'students:bulk:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const classId = requiredString(body, 'classId');
|
||||
const assignments = asObjectArray(body.assignments || body.students, 'assignments', MAX_BULK_CLASS_ASSIGNMENTS);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
await ensureTenantClass(client, auth.tenantId, classId);
|
||||
const items: unknown[] = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
for (let index = 0; index < assignments.length; index += 1) {
|
||||
const assignment = assignments[index];
|
||||
try {
|
||||
const memberType = optionalChoice(assignment.memberType, CLASS_MEMBER_TYPES, 'student');
|
||||
const status = optionalChoice(assignment.status, CLASS_MEMBER_STATUSES, 'active');
|
||||
const userId = await resolveOrCreateUser(client, assignment, membershipRoleForClassMember(memberType));
|
||||
await ensureTenantMembership(client, auth.tenantId, userId, membershipRoleForClassMember(memberType), 'active');
|
||||
if (memberType === 'student') {
|
||||
await client.query(
|
||||
`
|
||||
insert into public.student_profiles (tenant_id, user_id, stats, progress)
|
||||
values ($1, $2, '{}'::jsonb, '{}'::jsonb)
|
||||
on conflict (tenant_id, user_id) do nothing
|
||||
`,
|
||||
[auth.tenantId, userId],
|
||||
);
|
||||
}
|
||||
const item = await client.query(
|
||||
`
|
||||
insert into public.tenant_class_members (
|
||||
tenant_id, class_id, user_id, member_type, status, left_at,
|
||||
metadata, created_by, updated_by
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, case when $5 in ('disabled', 'removed') then now() else null end, $6::jsonb, $7, $7)
|
||||
on conflict (tenant_id, class_id, user_id, member_type)
|
||||
do update set status = excluded.status,
|
||||
left_at = excluded.left_at,
|
||||
metadata = excluded.metadata,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = now()
|
||||
returning id, class_id as "classId", user_id as "userId", member_type as "memberType", status
|
||||
`,
|
||||
[auth.tenantId, classId, userId, memberType, status, jsonBodyValue(assignment.metadata), auth.userId],
|
||||
);
|
||||
items.push({ index, ...item.rows[0] });
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
index,
|
||||
code: error instanceof HttpError ? error.code : 'CLASS_BULK_ASSIGN_ITEM_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Class assignment item failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(client, auth, 'tenant.class_members.bulk_assigned', 'tenant_class_members', classId, {
|
||||
classId,
|
||||
total: assignments.length,
|
||||
successCount: items.length,
|
||||
errorCount: errors.length,
|
||||
});
|
||||
return { total: assignments.length, successCount: items.length, errorCount: errors.length, items, errors };
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function tenantTeachersRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'classes:read');
|
||||
@@ -722,3 +980,262 @@ export async function tenantTeachersRoute(ctx: RequestContext) {
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function tenantStudentNotesRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'students:notes:read');
|
||||
const studentUserId = stringParam(ctx, 'studentUserId');
|
||||
if (!studentUserId) throw new HttpError(400, 'studentUserId is required', 'STUDENT_USER_ID_REQUIRED');
|
||||
await ensureStudentInScope(auth, studentUserId);
|
||||
const limit = intParam(ctx, 'limit', 100, 300);
|
||||
|
||||
const items = await query<Record<string, unknown>>(
|
||||
`
|
||||
select sn.id, sn.student_user_id as "studentUserId", sn.note_type as "noteType",
|
||||
sn.content, sn.visibility, sn.is_pinned as "isPinned", sn.metadata,
|
||||
sn.created_by as "createdBy", sn.updated_by as "updatedBy",
|
||||
sn.created_at as "createdAt", sn.updated_at as "updatedAt",
|
||||
creator.name as "createdByName", creator.username as "createdByUsername"
|
||||
from public.tenant_student_notes sn
|
||||
left join public.platform_users creator on creator.id = sn.created_by
|
||||
where sn.tenant_id = $1
|
||||
and sn.student_user_id = $2
|
||||
and (
|
||||
sn.visibility <> 'author_only'
|
||||
or sn.created_by = $3
|
||||
or $4::boolean
|
||||
)
|
||||
order by sn.is_pinned desc, sn.created_at desc
|
||||
limit $5
|
||||
`,
|
||||
[auth.tenantId, studentUserId, auth.userId, canReadAllClassScope(auth), limit],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertTenantStudentNoteRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'students:notes:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const studentUserId = requiredString(body, 'studentUserId');
|
||||
await ensureStudentInScope(auth, studentUserId);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.tenant_student_notes (
|
||||
id, tenant_id, student_user_id, note_type, content, visibility,
|
||||
is_pinned, metadata, created_by, updated_by
|
||||
)
|
||||
values (
|
||||
coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5, $6,
|
||||
$7, $8::jsonb, $9, $9
|
||||
)
|
||||
on conflict (id)
|
||||
do update set note_type = excluded.note_type,
|
||||
content = excluded.content,
|
||||
visibility = excluded.visibility,
|
||||
is_pinned = excluded.is_pinned,
|
||||
metadata = excluded.metadata,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = now()
|
||||
where public.tenant_student_notes.tenant_id = excluded.tenant_id
|
||||
and public.tenant_student_notes.student_user_id = excluded.student_user_id
|
||||
and (
|
||||
public.tenant_student_notes.created_by = $9
|
||||
or $10::boolean
|
||||
)
|
||||
returning id, student_user_id as "studentUserId", note_type as "noteType",
|
||||
content, visibility, is_pinned as "isPinned", metadata,
|
||||
created_by as "createdBy", updated_by as "updatedBy",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
nullableString(body.id),
|
||||
studentUserId,
|
||||
optionalChoice(body.noteType, STUDENT_NOTE_TYPES, 'general'),
|
||||
requiredString(body, 'content'),
|
||||
optionalChoice(body.visibility, STUDENT_NOTE_VISIBILITIES, 'tenant_staff'),
|
||||
boolValue(body.isPinned, false),
|
||||
jsonBodyValue(body.metadata),
|
||||
auth.userId,
|
||||
canReadAllClassScope(auth),
|
||||
],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Student note not found or not editable', 'STUDENT_NOTE_NOT_EDITABLE');
|
||||
await recordAudit(client, auth, 'tenant.student_note.upserted', 'tenant_student_notes', result.rows[0].id, {
|
||||
studentUserId,
|
||||
noteType: result.rows[0].noteType,
|
||||
});
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function tenantStudentFollowupsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'students:followups:read');
|
||||
const limit = intParam(ctx, 'limit', 100, 300);
|
||||
const studentUserId = stringParam(ctx, 'studentUserId');
|
||||
const status = stringParam(ctx, 'status');
|
||||
const assignedTo = stringParam(ctx, 'assignedToUserId');
|
||||
const scopedIds = await scopedClassIds(auth);
|
||||
if (studentUserId) await ensureStudentInScope(auth, studentUserId);
|
||||
if (status && !STUDENT_FOLLOWUP_STATUSES.includes(status)) {
|
||||
throw new HttpError(400, `Invalid follow-up status: ${status}`, 'INVALID_FOLLOWUP_STATUS');
|
||||
}
|
||||
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['sf.tenant_id = $1'];
|
||||
if (studentUserId) {
|
||||
params.push(studentUserId);
|
||||
filters.push(`sf.student_user_id = $${params.length}::uuid`);
|
||||
}
|
||||
if (status) {
|
||||
params.push(status);
|
||||
filters.push(`sf.status = $${params.length}`);
|
||||
}
|
||||
if (assignedTo) {
|
||||
params.push(assignedTo);
|
||||
filters.push(`sf.assigned_to_user_id = $${params.length}::uuid`);
|
||||
}
|
||||
if (scopedIds) {
|
||||
params.push(scopedIds);
|
||||
filters.push(`exists (
|
||||
select 1 from public.tenant_class_members scoped_cm
|
||||
where scoped_cm.tenant_id = sf.tenant_id
|
||||
and scoped_cm.user_id = sf.student_user_id
|
||||
and scoped_cm.class_id = any($${params.length}::uuid[])
|
||||
and scoped_cm.member_type = 'student'
|
||||
and scoped_cm.status = 'active'
|
||||
)`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query<Record<string, unknown>>(
|
||||
`
|
||||
select sf.id, sf.student_user_id as "studentUserId",
|
||||
student.name as "studentName", student.phone as "studentPhone",
|
||||
sf.assigned_to_user_id as "assignedToUserId", assignee.name as "assignedToName",
|
||||
sf.class_id as "classId", tc.name as "className",
|
||||
sf.title, sf.description, sf.followup_type as "followupType",
|
||||
sf.priority, sf.status, sf.due_at as "dueAt",
|
||||
sf.completed_at as "completedAt", sf.completed_by as "completedBy",
|
||||
sf.metadata, sf.created_by as "createdBy", sf.updated_by as "updatedBy",
|
||||
sf.created_at as "createdAt", sf.updated_at as "updatedAt"
|
||||
from public.tenant_student_followups sf
|
||||
left join public.platform_users student on student.id = sf.student_user_id
|
||||
left join public.platform_users assignee on assignee.id = sf.assigned_to_user_id
|
||||
left join public.tenant_classes tc on tc.tenant_id = sf.tenant_id and tc.id = sf.class_id
|
||||
where ${filters.join(' and ')}
|
||||
order by case sf.priority
|
||||
when 'urgent' then 1
|
||||
when 'high' then 2
|
||||
when 'normal' then 3
|
||||
else 4
|
||||
end, sf.due_at asc nulls last, sf.created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items: items.map(item => maskStudentFields(auth, item)) };
|
||||
}
|
||||
|
||||
export async function upsertTenantStudentFollowupRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'students:followups:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const studentUserId = requiredString(body, 'studentUserId');
|
||||
await ensureStudentInScope(auth, studentUserId);
|
||||
const requestedClassId = nullableString(body.classId);
|
||||
if (requestedClassId) await ensureReadableClass(auth, requestedClassId);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const classId = requestedClassId;
|
||||
const assignedToUserId = nullableString(body.assignedToUserId);
|
||||
if (classId) await ensureTenantClass(client, auth.tenantId, classId);
|
||||
await ensureUserTenantMembership(
|
||||
client,
|
||||
auth.tenantId,
|
||||
assignedToUserId,
|
||||
['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'],
|
||||
'ASSIGNEE_NOT_FOUND',
|
||||
);
|
||||
const status = optionalChoice(body.status, STUDENT_FOLLOWUP_STATUSES, 'open');
|
||||
const completedAt = status === 'done' ? nullableString(body.completedAt) || new Date().toISOString() : null;
|
||||
const completedBy = status === 'done' ? auth.userId : null;
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.tenant_student_followups (
|
||||
id, tenant_id, student_user_id, assigned_to_user_id, class_id,
|
||||
title, description, followup_type, priority, status, due_at,
|
||||
completed_at, completed_by, metadata, created_by, updated_by
|
||||
)
|
||||
values (
|
||||
coalesce($2::uuid, gen_random_uuid()), $1, $3, $4::uuid, $5::uuid,
|
||||
$6, $7, $8, $9, $10, $11::timestamptz,
|
||||
$12::timestamptz, $13::uuid, $14::jsonb, $15, $15
|
||||
)
|
||||
on conflict (id)
|
||||
do update set assigned_to_user_id = excluded.assigned_to_user_id,
|
||||
class_id = excluded.class_id,
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
followup_type = excluded.followup_type,
|
||||
priority = excluded.priority,
|
||||
status = excluded.status,
|
||||
due_at = excluded.due_at,
|
||||
completed_at = case
|
||||
when excluded.status = 'done' then coalesce(excluded.completed_at, public.tenant_student_followups.completed_at, now())
|
||||
else null
|
||||
end,
|
||||
completed_by = case
|
||||
when excluded.status = 'done' then coalesce(excluded.completed_by, public.tenant_student_followups.completed_by)
|
||||
else null
|
||||
end,
|
||||
metadata = excluded.metadata,
|
||||
updated_by = excluded.updated_by,
|
||||
updated_at = now()
|
||||
where public.tenant_student_followups.tenant_id = excluded.tenant_id
|
||||
and public.tenant_student_followups.student_user_id = excluded.student_user_id
|
||||
returning id, student_user_id as "studentUserId",
|
||||
assigned_to_user_id as "assignedToUserId", class_id as "classId",
|
||||
title, description, followup_type as "followupType", priority, status,
|
||||
due_at as "dueAt", completed_at as "completedAt", completed_by as "completedBy",
|
||||
metadata, created_by as "createdBy", updated_by as "updatedBy",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
nullableString(body.id),
|
||||
studentUserId,
|
||||
assignedToUserId,
|
||||
classId,
|
||||
requiredString(body, 'title'),
|
||||
nullableString(body.description),
|
||||
optionalChoice(body.followupType, STUDENT_FOLLOWUP_TYPES, 'learning'),
|
||||
optionalChoice(body.priority, STUDENT_FOLLOWUP_PRIORITIES, 'normal'),
|
||||
status,
|
||||
nullableString(body.dueAt),
|
||||
completedAt,
|
||||
completedBy,
|
||||
jsonBodyValue(body.metadata),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Student follow-up not found', 'STUDENT_FOLLOWUP_NOT_FOUND');
|
||||
await recordAudit(client, auth, 'tenant.student_followup.upserted', 'tenant_student_followups', result.rows[0].id, {
|
||||
studentUserId,
|
||||
status,
|
||||
assignedToUserId,
|
||||
classId,
|
||||
});
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import {
|
||||
disableTenantClassRoute,
|
||||
bulkAssignTenantClassMembersRoute,
|
||||
bulkUpsertTenantStudentsRoute,
|
||||
removeTenantClassMemberRoute,
|
||||
tenantClassesRoute,
|
||||
tenantClassMembersRoute,
|
||||
tenantStudentFollowupsRoute,
|
||||
tenantStudentNotesRoute,
|
||||
tenantStudentsRoute,
|
||||
tenantTeachersRoute,
|
||||
updateTenantStudentStatusRoute,
|
||||
upsertTenantStudentFollowupRoute,
|
||||
upsertTenantStudentNoteRoute,
|
||||
upsertTenantClassMemberRoute,
|
||||
upsertTenantClassRoute,
|
||||
upsertTenantStudentRoute,
|
||||
@@ -56,8 +63,15 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant-admin/classes/members', tenantClassMembersRoute],
|
||||
['PUT', '/api/tenant-admin/classes/members', upsertTenantClassMemberRoute],
|
||||
['POST', '/api/tenant-admin/classes/members/remove', removeTenantClassMemberRoute],
|
||||
['POST', '/api/tenant-admin/classes/members/bulk-assign', bulkAssignTenantClassMembersRoute],
|
||||
['GET', '/api/tenant-admin/students', tenantStudentsRoute],
|
||||
['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute],
|
||||
['POST', '/api/tenant-admin/students/bulk-upsert', bulkUpsertTenantStudentsRoute],
|
||||
['POST', '/api/tenant-admin/students/status', updateTenantStudentStatusRoute],
|
||||
['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute],
|
||||
['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute],
|
||||
['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute],
|
||||
['PUT', '/api/tenant-admin/students/followups', upsertTenantStudentFollowupRoute],
|
||||
['GET', '/api/tenant-admin/teachers', tenantTeachersRoute],
|
||||
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
|
||||
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
| 租户角色权限 | 可联调 | `tenant_memberships.role + permissions + role_template_id`,接口有权限点校验 |
|
||||
| 自定义角色模板 | 可联调 | `tenant_role_templates` + `/api/tenant-admin/role-templates`,支持权限、菜单、模块、字段、数据范围配置;前端 UI 继续补 |
|
||||
| 班级/教师/学生范围权限 | 可联调 | `tenant_classes`、`tenant_class_members` + `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`;教师默认只看自己负责班级,字段权限可脱敏学生手机号 |
|
||||
| 学生运营备注和跟进 | 可联调 | `tenant_student_notes`、`tenant_student_followups` + `/api/tenant-admin/students/notes`、`students/followups`;教师/班主任只能操作范围内学生,支持备注可见性、任务指派、完成状态和审计 |
|
||||
|
||||
## 学生端题库主链路
|
||||
|
||||
@@ -116,6 +117,7 @@
|
||||
| 激活码批次/生成/列表 | 可联调 | `/api/tenant-admin/code-batches`、`activation-codes` |
|
||||
| 成员/角色权限/审计 | 可联调 | `/api/tenant-admin/members`、`permissions`、`role-templates`、`audit-logs` |
|
||||
| 班级/学生/教师管理 | 可联调 | `/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`,支持班级范围权限和审计 |
|
||||
| 学生批量运营 | 可联调 | `/api/tenant-admin/students/bulk-upsert`、`students/status`、`classes/members/bulk-assign`、`students/notes`、`students/followups`;支持逐行结果、限量、防跨租户和教师范围校验 |
|
||||
| 平台租户/套餐/订阅/账单/用量 | 可联调 | `/api/platform-admin/*` |
|
||||
| 数据看板聚合接口 | 待补齐 | 表基础已有,缺完整 dashboard API |
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- 鉴权上下文已支持 Supabase Auth JWT 和迁移期 `tk_` session 双入口,JWT 通过 `auth.users.id -> platform_users.auth_user_id -> tenant_memberships` 映射业务用户和租户;平台管理员 JWT 已可访问平台后台。
|
||||
- 租户自定义角色模板已落库:`tenant_role_templates` 支持权限、菜单、模块、字段和数据范围配置,成员可通过 `role_template_id` 绑定模板。
|
||||
- 班级与学生范围权限已落库:`tenant_classes`、`tenant_class_members` 支持教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||
- 学生运营管理已落库:`tenant_student_notes`、`tenant_student_followups` 支持学生备注、家校/班主任/销售跟进任务、可见性、指派、完成状态和审计;批量学生 upsert、批量分班、禁用/恢复也已接入权限校验。
|
||||
- `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
|
||||
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
|
||||
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
|
||||
@@ -145,8 +146,15 @@ POST /api/tenant-admin/classes/disable
|
||||
GET /api/tenant-admin/classes/members
|
||||
PUT /api/tenant-admin/classes/members
|
||||
POST /api/tenant-admin/classes/members/remove
|
||||
POST /api/tenant-admin/classes/members/bulk-assign
|
||||
GET /api/tenant-admin/students
|
||||
PUT /api/tenant-admin/students
|
||||
POST /api/tenant-admin/students/bulk-upsert
|
||||
POST /api/tenant-admin/students/status
|
||||
GET /api/tenant-admin/students/notes
|
||||
PUT /api/tenant-admin/students/notes
|
||||
GET /api/tenant-admin/students/followups
|
||||
PUT /api/tenant-admin/students/followups
|
||||
GET /api/tenant-admin/teachers
|
||||
GET /api/tenant-admin/overview
|
||||
PUT /api/tenant-admin/branding
|
||||
@@ -192,6 +200,7 @@ GET /api/tenant-admin/audit-logs
|
||||
- `tenant-admin` 采用角色默认权限 + `tenant_memberships.permissions` 覆盖的权限矩阵。成员可进入后台,但每个接口会校验具体权限点;学生和跨租户成员会被拒绝。
|
||||
- 当前默认角色:`tenant_owner`/`tenant_admin` 全权限,`tenant_operator` 可维护内容和活动,`teacher` 可维护内容并按班级范围查看学生,`sales` 可维护激活码和优惠券,`agent` 只读部分兑换码/优惠券。
|
||||
- 班级学生 API 会按 `tenant_memberships.role_template_id -> tenant_role_templates.data_scope`、成员显式权限和 `tenant_class_members` 共同确定可见范围;非全局权限教师只能查看自己负责班级的学生。
|
||||
- 学生批量导入、批量分班、学生状态、备注和跟进任务都使用独立权限点;教师默认可为范围内学生写备注和跟进任务,但不能批量导入、禁用学生或放大可见班级。
|
||||
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
|
||||
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
|
||||
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider。真实对象存在性校验、PDF 预览渲染、防盗链、水印和大文件上传后 worker 校验仍需继续补。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 旧题库功能差距矩阵
|
||||
|
||||
更新时间:2026-06-28
|
||||
更新时间:2026-06-29
|
||||
|
||||
旧项目位于 `F:\project\参考\旧题库项目`,只作为功能、样式、交互和迁移参考。新项目不再复刻 PocketBase/SQLite 的数据结构,而是以新多租户 SaaS 模型为准。
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
- `已覆盖`:新后端已有对应模型和接口。
|
||||
- `部分覆盖`:已有主干,但商用体验或边界还要补。
|
||||
- `本地待推`:本地代码已实现并通过过验证,但尚未提交/推送到 Gitea。
|
||||
- `未覆盖`:需要新增后端能力。
|
||||
- `前端为主`:后端已有基础,主要由 Taro/H5 实现展示和交互。
|
||||
|
||||
@@ -15,32 +16,35 @@
|
||||
|
||||
| 旧功能/页面 | 旧项目参考 | 新后端状态 | 待补齐 |
|
||||
| --- | --- | --- | --- |
|
||||
| 登录/注册 | `pages/Login.tsx` | 部分覆盖 | 短信 mock/session 已有;微信小程序、微信网页、QQ、正式 JWT 未完成 |
|
||||
| 登录/注册 | `pages/Login.tsx` | 部分覆盖 | 短信、Supabase JWT、微信小程序登录主链路已有;微信网页登录、QQ OAuth、手机号换绑/补绑和生产账号联调待补 |
|
||||
| 选地区 | `pages/RegionSelector.tsx` | 已覆盖 | 需要前端按租户套餐和权益展示可选地区 |
|
||||
| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、入口、个人统计有基础;缺完整运营动态/学习任务聚合 |
|
||||
| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、FAQ、时间线、入口、个人统计有基础;缺考试倒计时 API、完整运营动态和学习任务聚合 |
|
||||
| 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` |
|
||||
| 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 |
|
||||
| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验、session 快照、练习历史和趋势统计已由后端强制;继续补断点续练、更多题型渲染 |
|
||||
| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验、session 快照、练习历史和趋势统计已由后端强制;继续补断点续练和题目反馈 |
|
||||
| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照、访问控制和历史统计,前端需按 mode 调用 |
|
||||
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排名、断点续练、复盘体验 |
|
||||
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | blueprint、session 快照、交卷评分、分段统计和错题解析汇总已覆盖;后续补排行榜/排名、断点续练、复盘体验 |
|
||||
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 错题列表、移出错题、复习计划和 `wrong_review` 后端组卷已覆盖;后续补更细的间隔复习算法 |
|
||||
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
|
||||
| 题目视频 | `VideoPlayer.tsx` | 部分覆盖 | 题目视频查询、播放签名、SVIP/次数扣减、播放日志已有;缺深度防盗链、动态水印、播放统计报表 |
|
||||
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音策略和更精细的间隔算法参数 |
|
||||
| 背单词 | `VocabularyPage.tsx`、`VocabularyQuiz.tsx` | 部分覆盖 | 单词列表、进度、收藏、统计、每日计划和后端复习调度已覆盖;后续补收藏练习体验、发音/音频策略、排行榜和更精细的间隔算法参数 |
|
||||
| 知识手册 | `Handbook*.tsx` | 已覆盖 | 前端需做好 Markdown/公式/图片渲染和搜索体验 |
|
||||
| 分数线 | `ScorelinePage.tsx` | 已覆盖 | 动态字段/趋势已有;缺批量导入和复杂筛选优化 |
|
||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐/订单/权益/激活码已有;缺真实支付、优惠券抵扣 |
|
||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计和趋势已有;缺完整勋章、签到、学习报告可视化 |
|
||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、下载水印、防盗链 |
|
||||
| 商城/SVIP | `Store.tsx`、`SvipModal.tsx` | 部分覆盖 | 套餐/订单/权益/激活码、微信支付/支付宝 provider 主链路已有;缺优惠券下单抵扣、订单状态轮询、激活码预检查、退款/对账/补偿任务 |
|
||||
| 个人中心 | `Profile.tsx` | 部分覆盖 | 基本资料、权益、订单统计、练习历史、学习统计和趋势已有;缺勋章 API、签到积分、考试倒计时、账号绑定/换绑、学习报告可视化 |
|
||||
| 资料下载 | `QuestionExporterPublishModal.tsx` 等 | 部分覆盖 | 资源台账/签名下载已有;缺 PDF 预览、水印、防盗链和上传后对象校验 |
|
||||
| AI 择校推荐 | 业务规划新增 | 未覆盖 | 需设计学生输入 schema、地区数据上下文、AI JSON 输出、PDF 报告 |
|
||||
| 题目反馈 | `02-API接口.md` 用户反馈 | 未覆盖 | 需新增题目反馈表、提交接口、租户后台处理流和通知 |
|
||||
| 签到积分 | `Profile.tsx`、`02-API接口.md` | 未覆盖 | 旧用户表有 `lastCheckInDate/score`;新 schema 有字段基础,但缺签到 API、积分流水和活动规则 |
|
||||
| 排行榜 | `leaderboard.pb.js`、`02-API接口.md` | 未覆盖 | 需新增题库/模考/背单词排行榜聚合接口和防刷策略 |
|
||||
|
||||
## 租户后台功能
|
||||
|
||||
| 旧功能/组件 | 新后端状态 | 待补齐 |
|
||||
| --- | --- | --- |
|
||||
| 用户管理 | 部分覆盖 | 租户成员 API 已有;学生用户列表、批量导入、禁用/补绑/CRM 批量推送还需完善 |
|
||||
| 用户管理 | 本地待推 | 租户成员、学生列表、学生资料、批量学生 upsert、禁用/恢复、批量分班、学生备注、跟进任务已在本地实现;批量 CRM 推送、补绑、学习督导自动化待补 |
|
||||
| 销售/代理管理 | 部分覆盖 | referral/team/stats 有;缺分佣比例、结算单、审核、导出 |
|
||||
| 班级/教师管理 | 未覆盖 | 需新增班级、学生分班、教师可见范围 |
|
||||
| 班级/教师管理 | 已覆盖 | 班级、班级成员、教师/班主任/助教/学生范围权限已有;可视化 UI 和更细数据范围组合待补 |
|
||||
| 数据看板 | 部分覆盖 | 表基础有;缺收益、注册、答题、活跃、套餐销量等聚合 API |
|
||||
| 地区管理 | 部分覆盖 | 地区和内容入口已有;缺按 SaaS 套餐限制地区/题库授权的完整流程 |
|
||||
| 品牌配置 | 已覆盖 | 需要前端做预览和主题发布体验 |
|
||||
@@ -54,13 +58,13 @@
|
||||
| 激活码 | 已覆盖 | 批次、生成、兑换主链路已有 |
|
||||
| 勋章管理 | 部分覆盖 | 表结构有 badges/user_badges;缺后台和学生端 API |
|
||||
| 题库录入 | 已覆盖 | 单题创建/更新、JSON 导入、集合/蓝图已有 |
|
||||
| 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定是否服务端导出或前端导出 |
|
||||
| 题库导出 PDF/Word/JSON | 未覆盖 | 旧前端有导出组件;新后端需决定服务端导出、导出水印和权限审计 |
|
||||
| 题型分组/模拟卷配置 | 部分覆盖 | question_type_groups 表和 blueprint 有基础;后台配置体验待补 |
|
||||
| 背单词维护 | 已覆盖 | 单元/单词 CRUD 和导入已有 |
|
||||
| 知识手册维护 | 已覆盖 | subject/chapter/entry CRUD 和导入已有 |
|
||||
| 分数线维护 | 已覆盖 | 字段/院校/专业/记录 CRUD 已有 |
|
||||
| 视频维护/绑定 | 已覆盖 | video CRUD 和 question-video 绑定已有 |
|
||||
| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;发送 worker 待补 |
|
||||
| CRM 配置和队列 | 部分覆盖 | 配置/队列已有;钉钉/飞书/企微真实发送 worker、签名、重试、死信待补 |
|
||||
| 对象存储配置 | 部分覆盖 | 系统 env provider 已有;租户级存储策略、上传后校验待补 |
|
||||
|
||||
## 平台 SaaS 后台功能
|
||||
@@ -71,7 +75,7 @@
|
||||
| SaaS 套餐 | 已覆盖 | 需要和地区/题库授权策略打通 |
|
||||
| 年费/服务费账单 | 已覆盖 | 真实支付/开票/催缴流程待补 |
|
||||
| 租户用量记录 | 已覆盖 | 自动采集 worker 待补 |
|
||||
| 公共题库/地区题库 | 部分覆盖 | question_banks 有 source_scope;缺租户采纳、授权、版本同步 |
|
||||
| 公共题库/地区题库 | 部分覆盖 | question_banks 有 source_scope;缺平台披露策略、SaaS 套餐授权、租户采纳/复制/版本同步 |
|
||||
| 跨租户运营看板 | 部分覆盖 | overview 有基础;缺完整 BI 聚合 |
|
||||
| 租户安全审计 | 部分覆盖 | audit logs 有;缺平台级审计报表 |
|
||||
|
||||
@@ -86,6 +90,21 @@
|
||||
|
||||
## 后端补齐优先级
|
||||
|
||||
## 和旧题库相比仍缺的明确功能
|
||||
|
||||
这些是旧项目中已经出现过、但新后端还没有完整业务闭环的功能:
|
||||
|
||||
1. 题目反馈:学生提交题目纠错、租户后台处理、状态流转、处理通知。
|
||||
2. 签到积分:每日签到、积分流水、连续签到、积分和活动/兑换的关系。
|
||||
3. 排行榜:刷题、模考、背单词排行榜,以及防刷、租户/地区/班级维度。
|
||||
4. 考试倒计时:`exam_dates` 表已有,但还缺学生端查询和后台维护 API。
|
||||
5. 订单状态轮询和激活码预检查:订单列表和兑换已有,但旧商城体验需要更细的状态查询/预检接口。
|
||||
6. 账号设置完整流:头像上传、绑定/更换手机号、微信/QQ 账号合并、密码/邮箱能力。
|
||||
7. 题库导出:PDF/Word/JSON 导出、水印、导出审计和权限控制。
|
||||
8. 导入扩展:Excel/CSV、分数线、视频批量导入和大批量异步 worker。
|
||||
9. 公共题库商业化:平台公共/地区题库披露、租户采纳、套餐授权、版本同步。
|
||||
10. CRM/销售结算:真实 CRM worker、轮询/定向分配、分佣规则、结算单、审核和导出。
|
||||
|
||||
### P0:前端联调到云端前
|
||||
|
||||
1. 正式鉴权:API 已支持 Supabase Auth JWT;继续补真实云端 Auth/JWKS 回归、RLS 深测和自定义角色权限细化。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 多租户与鉴权安全契约
|
||||
|
||||
更新时间:2026-06-28
|
||||
更新时间:2026-06-29
|
||||
|
||||
这个系统后续要卖给同行作为题库 SaaS,因此租户隔离、鉴权、资源权限和审计是商用红线。前端可以先按迁移期接口联调,也可以按 Supabase 官方推荐使用 publishable key + RLS 的客户端能力管理 Auth/session,但正式上云验收前必须完成本文件的 P0 项。
|
||||
|
||||
@@ -159,6 +159,9 @@ provider event id 幂等
|
||||
- `tenant_classes` 和 `tenant_class_members` 提供班级、班主任、教师、助教、学生分组边界。
|
||||
- 教师如无 `classes:write`、`students:write`、`members:read` 等全局管理权限,只能查看自己在 `tenant_class_members` 中负责的班级及这些班级下的学生;也可由角色模板 `dataScope.classIds` 显式限定。
|
||||
- 学生手机号等敏感字段可由 `fieldPermissions` 控制,后端会对不可见字段返回 `null`,前端不得绕过其它接口补取。
|
||||
- 学生批量导入、学生禁用/恢复、批量分班、备注、跟进任务分别使用 `students:bulk:write`、`students:status:write`、`students:notes:*`、`students:followups:*` 权限点。
|
||||
- 教师默认只可对范围内学生创建备注和跟进任务;批量导入、禁用/恢复学生、跨班级指派跟进任务必须显式授权并通过后端范围校验。
|
||||
- 备注支持 `tenant_staff`、`class_staff`、`author_only` 可见性;`author_only` 备注只能由作者或全局学生管理权限账号更新。
|
||||
|
||||
后续要补:
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- Supabase/PostgreSQL 多租户 schema、RLS、索引、触发器。
|
||||
- Node.js API 分层:`core/features`。
|
||||
- 学生端核心 API:题库、练习、答题、模考交卷报告、练习历史、学习统计、错题复习计划、错题、收藏、背单词、知识手册、分数线、视频播放签名、资料、订单、权益、个人中心。
|
||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理。
|
||||
- 租户后台 API:品牌、域名、设置、支付账户、登录 provider、私密密钥、活动、激活码、优惠券、成员权限、审计、内容管理、班级/教师/学生、学生批量导入、批量分班、学生备注、跟进任务。
|
||||
- 平台后台 API:租户、SaaS 套餐、订阅、账单、服务费收款、用量。
|
||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
|
||||
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
||||
@@ -18,6 +18,7 @@
|
||||
- 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。
|
||||
- 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。
|
||||
- 租户组织范围:班级、班级成员、教师/班主任/助教/学生分组,教师按负责班级查看学生,字段权限可脱敏学生手机号。
|
||||
- 学生运营管理:学生批量 upsert、禁用/恢复、批量分班、备注、跟进任务已完成接口和集成测试;后续补批量 CRM 推送和自动学习督导。
|
||||
- 本地验证:`npm run check:refactor` 已通过。
|
||||
|
||||
当前更适合进入前端联调前阅读的总览文档:
|
||||
@@ -90,8 +91,8 @@
|
||||
- 销售/代理转化、分佣结算、客资跟进效果。
|
||||
|
||||
8. 学生运营管理
|
||||
- 已完成学生列表、学生资料维护、班级分组和教师范围可见。
|
||||
- 继续补学生批量导入、禁用/恢复、批量分班、批量 CRM 推送、学习督导任务和家校/班主任备注。
|
||||
- 已完成学生列表、学生资料维护、班级分组、教师范围可见、学生批量导入、禁用/恢复、批量分班、学生备注和跟进任务。
|
||||
- 继续补批量 CRM 推送、学习督导自动化、跟进效果统计和前端 UI。
|
||||
|
||||
9. AI 择校推荐
|
||||
- 地区考试数据上下文。
|
||||
|
||||
@@ -172,8 +172,10 @@ tenant:<tenantId>:theme
|
||||
| 个人中心 | `GET/PATCH /api/profile/me` |
|
||||
| 销售分享 | `/api/referral/resolve`、`track-event`、`bind` |
|
||||
| 租户班级 | `GET/PUT /api/tenant-admin/classes`、`POST /api/tenant-admin/classes/disable` |
|
||||
| 班级成员 | `GET/PUT /api/tenant-admin/classes/members`、`POST /api/tenant-admin/classes/members/remove` |
|
||||
| 租户学生 | `GET/PUT /api/tenant-admin/students` |
|
||||
| 班级成员 | `GET/PUT /api/tenant-admin/classes/members`、`POST /api/tenant-admin/classes/members/remove`、`POST /api/tenant-admin/classes/members/bulk-assign` |
|
||||
| 租户学生 | `GET/PUT /api/tenant-admin/students`、`POST /api/tenant-admin/students/bulk-upsert`、`POST /api/tenant-admin/students/status` |
|
||||
| 学生备注 | `GET/PUT /api/tenant-admin/students/notes` |
|
||||
| 学生跟进任务 | `GET/PUT /api/tenant-admin/students/followups` |
|
||||
| 租户教师 | `GET /api/tenant-admin/teachers` |
|
||||
|
||||
## 练习访问控制契约
|
||||
@@ -444,6 +446,8 @@ content_entries
|
||||
- 管理后台菜单按 `GET /api/tenant-admin/permissions` 返回的 `current.permissions`、`current.templatePermissions`、`current.menuPermissions`、`current.modulePermissions` 渲染;接口权限仍以后端校验为准。
|
||||
- 教师、班主任、助教类账号进入租户后台时,学生列表以 `GET /api/tenant-admin/students` 返回的 `scoped` 和 `items` 为准;前端不要自行用本地班级 ID 放大查询范围。
|
||||
- 学生手机号、订单金额、客资归属等敏感字段按 `fieldPermissions` 控制显示;字段被后端返回为 `null` 时前端展示脱敏占位,不要从其它接口补取。
|
||||
- 学生批量导入和批量分班接口会返回 `total/successCount/errorCount/results`,前端必须展示逐行错误,不要在浏览器端静默丢弃失败行。
|
||||
- 教师可以为范围内学生创建备注和跟进任务,但是否能禁用学生、批量导入、查看手机号由后端权限和字段权限决定;前端只按返回值渲染。
|
||||
- H5 自定义域名下要注意缓存隔离,不能把 A 租户主题缓存用到 B 租户。
|
||||
|
||||
## 登录对接
|
||||
@@ -608,7 +612,7 @@ GET /api/commerce/entitlements
|
||||
- 品牌/主题/域名/公开设置
|
||||
- 支付账户/登录 provider/密钥引用
|
||||
- 用户与成员权限
|
||||
- 班级/教师/学生:`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`
|
||||
- 班级/教师/学生:`/api/tenant-admin/classes`、`classes/members`、`students`、`teachers`、`students/notes`、`students/followups`
|
||||
- 角色模板:`GET/PUT /api/tenant-admin/role-templates`、`POST /api/tenant-admin/role-templates/disable`
|
||||
- 内容入口/分类树/题目集合/练习蓝图
|
||||
- 题目/单词/知识手册/分数线/视频维护
|
||||
|
||||
@@ -2762,6 +2762,222 @@ async function testTenantClassStudentScopes() {
|
||||
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.class_member.upserted'), 'audit logs should include class member upsert');
|
||||
}
|
||||
|
||||
async function testTenantStudentOperations() {
|
||||
let bulkStudentUserId = '';
|
||||
const bulk = await request('/api/tenant-admin/students/bulk-upsert', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
students: [
|
||||
{
|
||||
username: 'integration_bulk_student',
|
||||
phone: '13800000018',
|
||||
name: 'Integration Bulk Student',
|
||||
regionId: ids.region,
|
||||
legacyUserId: 'legacy-bulk-student',
|
||||
status: 'active',
|
||||
stats: { source: 'integration' },
|
||||
},
|
||||
{
|
||||
username: 'integration_bulk_missing_region',
|
||||
phone: '13800000019',
|
||||
name: 'Integration Bulk Missing Region',
|
||||
regionId: '00000000-0000-0000-0000-ffffffffffff',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(bulk.total, 2, 'bulk student upsert should report total');
|
||||
assert.equal(bulk.successCount, 1, 'bulk student upsert should import valid student');
|
||||
assert.equal(bulk.errorCount, 1, 'bulk student upsert should report invalid rows');
|
||||
bulkStudentUserId = bulk.items?.find(item => item.status === 'active')?.userId || '';
|
||||
assert.ok(bulkStudentUserId, 'bulk result should include created student id');
|
||||
assert.ok(bulk.errors?.some(item => item.code === 'REGION_NOT_FOUND'), 'bulk result should include row-level validation error');
|
||||
|
||||
const bulkLimitDenied = await request('/api/tenant-admin/students/bulk-upsert', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
students: Array.from({ length: 201 }, (_, index) => ({
|
||||
username: `too_many_${index}`,
|
||||
phone: `13988${String(index).padStart(6, '0')}`,
|
||||
})),
|
||||
},
|
||||
expectStatus: 413,
|
||||
});
|
||||
assert.ok(
|
||||
['BULK_LIMIT_EXCEEDED', 'JSON_BODY_TOO_LARGE'].includes(bulkLimitDenied.code),
|
||||
'bulk student upsert should enforce item or body limit',
|
||||
);
|
||||
|
||||
const bulkAssign = await request('/api/tenant-admin/classes/members/bulk-assign', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
classId: ids.tenantClass,
|
||||
assignments: [
|
||||
{ userId: bulkStudentUserId, memberType: 'student', status: 'active' },
|
||||
{ userId: '00000000-0000-0000-0000-ffffffffffff', memberType: 'student' },
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.equal(bulkAssign.total, 2, 'bulk class assign should report total');
|
||||
assert.equal(bulkAssign.successCount, 1, 'bulk class assign should assign valid student');
|
||||
assert.equal(bulkAssign.errorCount, 1, 'bulk class assign should report invalid rows');
|
||||
|
||||
const adminClassStudents = await request('/api/tenant-admin/students', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { classId: ids.tenantClass, keyword: 'Bulk Student' },
|
||||
});
|
||||
assert.ok(adminClassStudents.items?.some(item => item.userId === bulkStudentUserId), 'bulk assigned student should appear in class student list');
|
||||
|
||||
const disabled = await request('/api/tenant-admin/students/status', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: bulkStudentUserId,
|
||||
status: 'disabled',
|
||||
reason: 'integration-test',
|
||||
},
|
||||
});
|
||||
assert.equal(disabled.item?.status, 'disabled', 'tenant admin should disable student membership');
|
||||
|
||||
const disabledStudents = await request('/api/tenant-admin/students', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { status: 'disabled', keyword: 'Bulk Student' },
|
||||
});
|
||||
assert.ok(disabledStudents.items?.some(item => item.userId === bulkStudentUserId), 'disabled student should be queryable by status');
|
||||
|
||||
const restored = await request('/api/tenant-admin/students/status', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: bulkStudentUserId,
|
||||
status: 'active',
|
||||
reason: 'restore-integration-test',
|
||||
},
|
||||
});
|
||||
assert.equal(restored.item?.status, 'active', 'tenant admin should restore student membership');
|
||||
|
||||
const teacherNote = await request('/api/tenant-admin/students/notes', {
|
||||
userId: TENANT_TEACHER_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
studentUserId: USER_ID,
|
||||
noteType: 'learning',
|
||||
content: '该学生本周错题复习需要跟进。',
|
||||
visibility: 'class_staff',
|
||||
isPinned: true,
|
||||
metadata: { source: 'integration' },
|
||||
},
|
||||
});
|
||||
assert.equal(teacherNote.item?.noteType, 'learning', 'teacher should create note for scoped student');
|
||||
|
||||
const teacherNotes = await request('/api/tenant-admin/students/notes', {
|
||||
userId: TENANT_TEACHER_USER_ID,
|
||||
query: { studentUserId: USER_ID },
|
||||
});
|
||||
assert.ok(teacherNotes.items?.some(item => item.id === teacherNote.item.id), 'teacher should list scoped student notes');
|
||||
|
||||
const teacherOtherNoteDenied = await request('/api/tenant-admin/students/notes', {
|
||||
userId: TENANT_TEACHER_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
studentUserId: SECOND_STUDENT_USER_ID,
|
||||
noteType: 'learning',
|
||||
content: '不应允许教师给非负责学生写备注。',
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(teacherOtherNoteDenied.code, 'STUDENT_SCOPE_REQUIRED', 'teacher should not write note for unscoped student');
|
||||
|
||||
const followup = await request('/api/tenant-admin/students/followups', {
|
||||
userId: TENANT_TEACHER_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
studentUserId: USER_ID,
|
||||
assignedToUserId: TENANT_TEACHER_USER_ID,
|
||||
classId: ids.tenantClass,
|
||||
title: '错题复盘督导',
|
||||
description: '提醒学生完成本周错题复盘。',
|
||||
followupType: 'learning',
|
||||
priority: 'high',
|
||||
status: 'open',
|
||||
dueAt: '2026-07-01T10:00:00.000Z',
|
||||
},
|
||||
});
|
||||
assert.equal(followup.item?.status, 'open', 'teacher should create follow-up for scoped student');
|
||||
|
||||
const teacherFollowups = await request('/api/tenant-admin/students/followups', {
|
||||
userId: TENANT_TEACHER_USER_ID,
|
||||
query: { studentUserId: USER_ID, status: 'open' },
|
||||
});
|
||||
assert.ok(teacherFollowups.items?.some(item => item.id === followup.item.id), 'teacher should list scoped follow-ups');
|
||||
const visibleFollowup = teacherFollowups.items?.find(item => item.id === followup.item.id);
|
||||
assert.equal(visibleFollowup?.studentPhone, null, 'teacher follow-up list should mask student phone');
|
||||
|
||||
const completedFollowup = await request('/api/tenant-admin/students/followups', {
|
||||
userId: TENANT_TEACHER_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
id: followup.item.id,
|
||||
studentUserId: USER_ID,
|
||||
assignedToUserId: TENANT_TEACHER_USER_ID,
|
||||
classId: ids.tenantClass,
|
||||
title: '错题复盘督导',
|
||||
followupType: 'learning',
|
||||
priority: 'high',
|
||||
status: 'done',
|
||||
},
|
||||
});
|
||||
assert.equal(completedFollowup.item?.status, 'done', 'teacher should complete scoped follow-up');
|
||||
assert.equal(completedFollowup.item?.completedBy, TENANT_TEACHER_USER_ID, 'completed follow-up should record completer');
|
||||
|
||||
const teacherOtherFollowupDenied = await request('/api/tenant-admin/students/followups', {
|
||||
userId: TENANT_TEACHER_USER_ID,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
studentUserId: SECOND_STUDENT_USER_ID,
|
||||
classId: ids.tenantClassOther,
|
||||
title: '不应创建',
|
||||
followupType: 'learning',
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(teacherOtherFollowupDenied.code, 'STUDENT_SCOPE_REQUIRED', 'teacher should not create follow-up for unscoped student');
|
||||
|
||||
const studentStatusDenied = await request('/api/tenant-admin/students/status', {
|
||||
userId: TENANT_TEACHER_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: USER_ID,
|
||||
status: 'disabled',
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(studentStatusDenied.code, 'TENANT_PERMISSION_REQUIRED', 'teacher should not disable students without status permission');
|
||||
|
||||
const partnerBulkDenied = await request('/api/tenant-admin/students/bulk-upsert', {
|
||||
tenantId: PARTNER_TENANT_ID,
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
method: 'POST',
|
||||
body: {
|
||||
students: [{ userId: USER_ID, name: 'cross tenant denied' }],
|
||||
},
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(partnerBulkDenied.code, 'TENANT_ADMIN_REQUIRED', 'student bulk import must be tenant isolated');
|
||||
|
||||
const auditLogs = await request('/api/tenant-admin/audit-logs', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
query: { action: 'tenant.student', limit: 100 },
|
||||
});
|
||||
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.students.bulk_upserted'), 'audit logs should include bulk student upsert');
|
||||
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student.status_updated'), 'audit logs should include student status update');
|
||||
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student_note.upserted'), 'audit logs should include student note upsert');
|
||||
assert.ok(auditLogs.items?.some(item => item.action === 'tenant.student_followup.upserted'), 'audit logs should include student follow-up upsert');
|
||||
}
|
||||
|
||||
async function testReferralAndCrmGrowth() {
|
||||
const salesMember = await request('/api/tenant-admin/members', {
|
||||
userId: TENANT_ADMIN_USER_ID,
|
||||
@@ -2958,6 +3174,7 @@ async function main() {
|
||||
await check('tenant admin operations', testTenantAdminOps);
|
||||
await check('tenant member permissions and audit', testTenantMemberPermissionsAndAudit);
|
||||
await check('tenant class and student scopes', testTenantClassStudentScopes);
|
||||
await check('tenant student operations', testTenantStudentOperations);
|
||||
await check('referral and CRM growth', testReferralAndCrmGrowth);
|
||||
|
||||
console.log('API integration tests complete.');
|
||||
|
||||
92
supabase/migrations/202606290003_student_operations.sql
Normal file
92
supabase/migrations/202606290003_student_operations.sql
Normal file
@@ -0,0 +1,92 @@
|
||||
create table if not exists public.tenant_student_notes (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
student_user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
note_type text not null default 'general'
|
||||
check (note_type in ('general', 'learning', 'service', 'sales', 'risk', 'follow_up')),
|
||||
content text not null,
|
||||
visibility text not null default 'tenant_staff'
|
||||
check (visibility in ('tenant_staff', 'class_staff', 'author_only')),
|
||||
is_pinned boolean not null default false,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_by uuid references public.platform_users(id) on delete set null,
|
||||
updated_by uuid references public.platform_users(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table public.tenant_student_notes is
|
||||
'Tenant-scoped student operation notes for teachers, class advisers, operators and sales follow-up.';
|
||||
|
||||
create index if not exists idx_student_notes_tenant_student
|
||||
on public.tenant_student_notes(tenant_id, student_user_id, is_pinned desc, created_at desc);
|
||||
|
||||
create index if not exists idx_student_notes_tenant_creator
|
||||
on public.tenant_student_notes(tenant_id, created_by, created_at desc)
|
||||
where created_by is not null;
|
||||
|
||||
create table if not exists public.tenant_student_followups (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||
student_user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||
assigned_to_user_id uuid references public.platform_users(id) on delete set null,
|
||||
class_id uuid,
|
||||
title text not null,
|
||||
description text,
|
||||
followup_type text not null default 'learning'
|
||||
check (followup_type in ('learning', 'service', 'sales', 'renewal', 'risk', 'custom')),
|
||||
priority text not null default 'normal'
|
||||
check (priority in ('low', 'normal', 'high', 'urgent')),
|
||||
status text not null default 'open'
|
||||
check (status in ('open', 'in_progress', 'done', 'cancelled')),
|
||||
due_at timestamptz,
|
||||
completed_at timestamptz,
|
||||
completed_by uuid references public.platform_users(id) on delete set null,
|
||||
metadata jsonb not null default '{}'::jsonb,
|
||||
created_by uuid references public.platform_users(id) on delete set null,
|
||||
updated_by uuid references public.platform_users(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
foreign key (class_id) references public.tenant_classes(id) on delete set null
|
||||
);
|
||||
|
||||
comment on table public.tenant_student_followups is
|
||||
'Tenant-scoped student follow-up tasks for learning supervision, service, sales and renewal operations.';
|
||||
|
||||
create index if not exists idx_student_followups_tenant_student
|
||||
on public.tenant_student_followups(tenant_id, student_user_id, status, due_at nulls last, created_at desc);
|
||||
|
||||
create index if not exists idx_student_followups_tenant_assignee
|
||||
on public.tenant_student_followups(tenant_id, assigned_to_user_id, status, due_at nulls last)
|
||||
where assigned_to_user_id is not null;
|
||||
|
||||
create index if not exists idx_student_followups_tenant_class
|
||||
on public.tenant_student_followups(tenant_id, class_id, status, due_at nulls last)
|
||||
where class_id is not null;
|
||||
|
||||
alter table public.tenant_student_notes enable row level security;
|
||||
alter table public.tenant_student_followups enable row level security;
|
||||
|
||||
drop policy if exists tenant_isolation on public.tenant_student_notes;
|
||||
create policy tenant_isolation on public.tenant_student_notes
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop policy if exists tenant_isolation on public.tenant_student_followups;
|
||||
create policy tenant_isolation on public.tenant_student_followups
|
||||
for all
|
||||
using (tenant_id = app.current_tenant_id() or app.is_platform_admin())
|
||||
with check (tenant_id = app.current_tenant_id() or app.is_platform_admin());
|
||||
|
||||
drop trigger if exists set_updated_at on public.tenant_student_notes;
|
||||
create trigger set_updated_at
|
||||
before update on public.tenant_student_notes
|
||||
for each row
|
||||
execute function app.touch_updated_at();
|
||||
|
||||
drop trigger if exists set_updated_at on public.tenant_student_followups;
|
||||
create trigger set_updated_at
|
||||
before update on public.tenant_student_followups
|
||||
for each row
|
||||
execute function app.touch_updated_at();
|
||||
Reference in New Issue
Block a user