feat: add tenant class student scopes

This commit is contained in:
Codex
2026-06-29 00:50:22 +08:00
parent b262e87af9
commit 61240c5833
12 changed files with 1172 additions and 25 deletions

View File

@@ -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:*'],
teacher: ['content:*', 'classes:read', 'students:read'],
sales: ['codes:*', 'coupons:*', 'referral:*'],
agent: ['codes:read', 'coupons:read', 'referral:self'],
student: [],
@@ -102,6 +102,10 @@ export function tenantPermissionCatalog() {
{ key: 'referral:write', label: '客资归属管理' },
{ key: 'crm:read', label: 'CRM 队列查看' },
{ key: 'crm:write', label: 'CRM 入队和重试' },
{ key: 'classes:read', label: '班级查看' },
{ key: 'classes:write', label: '班级管理' },
{ key: 'students:read', label: '学生查看' },
{ key: 'students:write', label: '学生管理' },
{ key: 'members:read', label: '成员查看' },
{ key: 'members:write', label: '成员管理' },
{ key: 'roles:read', label: '角色模板查看' },

View File

@@ -0,0 +1,724 @@
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, transaction } from '../../core/db.js';
import {
hasTenantPermission,
requireTenantAdmin,
requireTenantPermission,
type TenantAdminAuth,
} from './auth.js';
type JsonBody = Record<string, unknown>;
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'];
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function objectValue(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function jsonBodyValue(value: unknown) {
return JSON.stringify(objectValue(value));
}
function intValue(value: unknown, fallback: number) {
const numberValue = Number(value ?? fallback);
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
}
function optionalChoice(value: unknown, allowed: string[], fallback: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function uuidArrayValue(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(item => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(item));
}
function classCodeValue(value: unknown) {
const code = nullableString(value);
if (!code) return null;
if (!/^[a-z0-9][a-z0-9_-]{1,63}$/i.test(code)) {
throw new HttpError(400, 'Invalid class code', 'INVALID_CLASS_CODE');
}
return code;
}
function canReadAllClassScope(auth: TenantAdminAuth) {
return (
auth.role === 'tenant_owner' ||
auth.role === 'tenant_admin' ||
hasTenantPermission(auth, 'classes:write') ||
hasTenantPermission(auth, 'students:write') ||
hasTenantPermission(auth, 'members:read')
);
}
function canSeeStudentPhone(auth: TenantAdminAuth) {
return auth.fieldPermissions?.['student.phone'] !== false;
}
function maskStudentFields<T extends Record<string, unknown>>(auth: TenantAdminAuth, item: T): T {
if (canSeeStudentPhone(auth)) return item;
return {
...item,
phone: null,
};
}
async function recordAudit(
client: pg.PoolClient,
auth: TenantAdminAuth,
action: string,
targetType: string,
targetId: string | null,
details: Record<string, unknown> = {},
) {
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, $4, $5, $6::jsonb)
`,
[auth.tenantId, auth.userId, action, targetType, targetId, JSON.stringify(details)],
);
}
async function scopedClassIds(auth: TenantAdminAuth) {
if (canReadAllClassScope(auth)) return null;
const explicit = uuidArrayValue(auth.dataScope?.classIds);
const rows = await query<{ id: string }>(
`
select distinct class_id as id
from public.tenant_class_members
where tenant_id = $1
and user_id = $2
and status = 'active'
and member_type in ('teacher', 'assistant', 'head_teacher')
`,
[auth.tenantId, auth.userId],
);
return Array.from(new Set([...explicit, ...rows.map(item => item.id)]));
}
async function ensureReadableClass(auth: TenantAdminAuth, classId: string) {
const allowedClassIds = await scopedClassIds(auth);
if (allowedClassIds && !allowedClassIds.includes(classId)) {
throw new HttpError(403, 'Class is outside the current data scope', 'CLASS_SCOPE_REQUIRED');
}
const rows = await query<{ id: string }>(
'select id from public.tenant_classes where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, classId],
);
if (!rows[0]) throw new HttpError(404, 'Class not found', 'CLASS_NOT_FOUND');
}
async function ensureTenantReference(
client: pg.PoolClient,
tableName: 'regions' | 'schools' | 'majors',
tenantId: string,
id: string | null,
errorCode: string,
) {
if (!id) return;
const result = await client.query<{ id: string }>(
`select id from public.${tableName} where tenant_id = $1 and id = $2 limit 1`,
[tenantId, id],
);
if (!result.rows[0]) throw new HttpError(400, `${tableName} id is not in this tenant`, errorCode);
}
async function ensureTenantClass(client: pg.PoolClient, tenantId: string, classId: string) {
const result = await client.query<{ id: string }>(
'select id from public.tenant_classes where tenant_id = $1 and id = $2 limit 1',
[tenantId, classId],
);
if (!result.rows[0]) throw new HttpError(404, 'Class not found', 'CLASS_NOT_FOUND');
}
async function resolveOrCreateUser(client: pg.PoolClient, body: JsonBody, fallbackPrimaryRole: string) {
const userId = nullableString(body.userId);
if (userId) {
const existing = await client.query<{ id: string }>(
'select id from public.platform_users where id = $1 limit 1',
[userId],
);
if (!existing.rows[0]) throw new HttpError(404, 'User not found', 'USER_NOT_FOUND');
await client.query(
`
update public.platform_users
set username = coalesce($2, username),
email = coalesce($3::citext, email),
phone = coalesce($4, phone),
name = coalesce($5, name),
avatar_url = coalesce($6, avatar_url),
primary_role = coalesce($7, primary_role),
updated_at = now()
where id = $1
`,
[
userId,
nullableString(body.username),
nullableString(body.email),
nullableString(body.phone),
nullableString(body.name),
nullableString(body.avatarUrl),
nullableString(body.primaryRole),
],
);
return userId;
}
const phone = nullableString(body.phone);
const email = nullableString(body.email);
const username = nullableString(body.username);
const name = nullableString(body.name);
if (!phone && !email && !username && !name) {
throw new HttpError(400, 'userId, phone, email, username, or name is required', 'USER_REQUIRED');
}
const found = await client.query<{ id: string }>(
`
select id
from public.platform_users
where ($1::text is not null and phone = $1)
or ($2::citext is not null and email = $2::citext)
or ($3::text is not null and username = $3)
order by created_at asc
limit 1
`,
[phone, email, username],
);
if (found.rows[0]) return found.rows[0].id;
const created = await client.query<{ id: string }>(
`
insert into public.platform_users (username, email, phone, name, avatar_url, primary_role, raw_profile)
values ($1, $2::citext, $3, $4, $5, $6, '{"source":"tenant-admin"}'::jsonb)
returning id
`,
[
username || phone || email,
email,
phone,
name || username || phone || email,
nullableString(body.avatarUrl),
fallbackPrimaryRole,
],
);
return created.rows[0].id;
}
async function ensureTenantMembership(
client: pg.PoolClient,
tenantId: string,
userId: string,
role: string,
status = 'active',
) {
await client.query(
`
insert into public.tenant_memberships (tenant_id, user_id, role, status)
values ($1, $2, $3, $4)
on conflict (tenant_id, user_id, role)
do update set status = excluded.status,
updated_at = now()
`,
[tenantId, userId, role, status],
);
}
function membershipRoleForClassMember(memberType: string) {
return memberType === 'student' ? 'student' : 'teacher';
}
export async function tenantClassesRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'classes:read');
const limit = intParam(ctx, 'limit', 100, 500);
const status = stringParam(ctx, 'status');
const regionId = stringParam(ctx, 'regionId');
const keyword = stringParam(ctx, 'keyword');
const scopedIds = await scopedClassIds(auth);
const params: unknown[] = [auth.tenantId];
const filters = ['tc.tenant_id = $1'];
if (status) {
if (!CLASS_STATUSES.includes(status)) throw new HttpError(400, `Invalid class status: ${status}`, 'INVALID_CLASS_STATUS');
params.push(status);
filters.push(`tc.status = $${params.length}`);
} else {
filters.push(`tc.status <> 'archived'`);
}
if (regionId) {
params.push(regionId);
filters.push(`tc.region_id = $${params.length}::uuid`);
}
if (keyword) {
params.push(`%${keyword}%`);
filters.push(`(tc.name ilike $${params.length} or tc.code ilike $${params.length})`);
}
if (scopedIds) {
params.push(scopedIds);
filters.push(`tc.id = any($${params.length}::uuid[])`);
}
params.push(limit);
const items = await query(
`
select tc.id, tc.region_id as "regionId", r.name as "regionName",
tc.legacy_id as "legacyId", tc.code, tc.name, tc.description,
tc.status, tc.sort_order as "sortOrder", tc.metadata,
tc.created_by as "createdBy", tc.updated_by as "updatedBy",
tc.created_at as "createdAt", tc.updated_at as "updatedAt",
coalesce(count(tcm.id) filter (where tcm.status = 'active' and tcm.member_type = 'student'), 0)::int as "studentCount",
coalesce(count(tcm.id) filter (where tcm.status = 'active' and tcm.member_type in ('teacher', 'assistant', 'head_teacher')), 0)::int as "teacherCount"
from public.tenant_classes tc
left join public.regions r on r.id = tc.region_id and r.tenant_id = tc.tenant_id
left join public.tenant_class_members tcm on tcm.tenant_id = tc.tenant_id and tcm.class_id = tc.id
where ${filters.join(' and ')}
group by tc.id, r.name
order by tc.sort_order asc, tc.created_at desc
limit $${params.length}
`,
params,
);
return { items, scoped: scopedIds !== null };
}
export async function upsertTenantClassRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'classes:write');
const body = await readJsonBody(ctx);
const item = await transaction(async client => {
const regionId = nullableString(body.regionId);
await ensureTenantReference(client, 'regions', auth.tenantId, regionId, 'REGION_NOT_FOUND');
const result = await client.query(
`
insert into public.tenant_classes (
id, tenant_id, region_id, legacy_id, code, name, description,
status, sort_order, metadata, created_by, updated_by
)
values (
coalesce($2::uuid, gen_random_uuid()), $1, $3::uuid, $4, $5, $6, $7,
$8, $9, $10::jsonb, $11, $11
)
on conflict (id)
do update set region_id = excluded.region_id,
legacy_id = coalesce(excluded.legacy_id, public.tenant_classes.legacy_id),
code = excluded.code,
name = excluded.name,
description = excluded.description,
status = excluded.status,
sort_order = excluded.sort_order,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
updated_at = now()
where public.tenant_classes.tenant_id = excluded.tenant_id
returning id, region_id as "regionId", legacy_id as "legacyId", code, name,
description, status, sort_order as "sortOrder", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
auth.tenantId,
nullableString(body.id),
regionId,
nullableString(body.legacyId),
classCodeValue(body.code),
requiredString(body, 'name'),
nullableString(body.description),
optionalChoice(body.status, CLASS_STATUSES, 'active'),
intValue(body.sortOrder, 0),
jsonBodyValue(body.metadata),
auth.userId,
],
);
if (!result.rows[0]) throw new HttpError(404, 'Class not found for this tenant', 'CLASS_NOT_FOUND');
await recordAudit(client, auth, 'tenant.class.upserted', 'tenant_classes', result.rows[0].id, {
code: result.rows[0].code,
name: result.rows[0].name,
});
return result.rows[0];
});
return { item };
}
export async function disableTenantClassRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'classes:write');
const body = await readJsonBody(ctx);
const classId = requiredString(body, 'classId');
const item = await transaction(async client => {
const result = await client.query(
`
update public.tenant_classes
set status = 'disabled',
updated_by = $3,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, code, name, status, updated_at as "updatedAt"
`,
[auth.tenantId, classId, auth.userId],
);
if (!result.rows[0]) throw new HttpError(404, 'Class not found', 'CLASS_NOT_FOUND');
await recordAudit(client, auth, 'tenant.class.disabled', 'tenant_classes', classId);
return result.rows[0];
});
return { item };
}
export async function tenantClassMembersRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'classes:read');
const classId = stringParam(ctx, 'classId');
if (!classId) throw new HttpError(400, 'classId is required', 'CLASS_ID_REQUIRED');
await ensureReadableClass(auth, classId);
const memberType = stringParam(ctx, 'memberType');
const status = stringParam(ctx, 'status') || 'active';
if (memberType && !CLASS_MEMBER_TYPES.includes(memberType)) {
throw new HttpError(400, `Invalid member type: ${memberType}`, 'INVALID_CLASS_MEMBER_TYPE');
}
if (!CLASS_MEMBER_STATUSES.includes(status)) {
throw new HttpError(400, `Invalid class member status: ${status}`, 'INVALID_CLASS_MEMBER_STATUS');
}
const params: unknown[] = [auth.tenantId, classId, status];
const filters = ['tcm.tenant_id = $1', 'tcm.class_id = $2::uuid', 'tcm.status = $3'];
if (memberType) {
params.push(memberType);
filters.push(`tcm.member_type = $${params.length}`);
}
const items = await query<Record<string, unknown>>(
`
select tcm.id, tcm.class_id as "classId", tcm.user_id as "userId",
tcm.member_type as "memberType", tcm.status, tcm.joined_at as "joinedAt",
tcm.left_at as "leftAt", tcm.metadata, tcm.created_at as "createdAt",
tcm.updated_at as "updatedAt",
u.username, u.email::text as email, u.phone, u.name,
u.avatar_url as "avatarUrl", u.primary_role as "primaryRole",
sp.region_id as "regionId", sp.selected_school_id as "selectedSchoolId",
sp.selected_major_id as "selectedMajorId"
from public.tenant_class_members tcm
join public.platform_users u on u.id = tcm.user_id
left join public.student_profiles sp on sp.tenant_id = tcm.tenant_id and sp.user_id = tcm.user_id
where ${filters.join(' and ')}
order by case tcm.member_type
when 'head_teacher' then 1
when 'teacher' then 2
when 'assistant' then 3
else 9
end, tcm.joined_at asc
`,
params,
);
return { items: items.map(item => maskStudentFields(auth, item)) };
}
export async function upsertTenantClassMemberRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'classes:write');
const body = await readJsonBody(ctx);
const classId = requiredString(body, 'classId');
const memberType = optionalChoice(body.memberType, CLASS_MEMBER_TYPES, 'student');
const status = optionalChoice(body.status, CLASS_MEMBER_STATUSES, 'active');
const item = await transaction(async client => {
await ensureTenantClass(client, auth.tenantId, classId);
const userId = await resolveOrCreateUser(client, body, 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 result = 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, joined_at as "joinedAt",
left_at as "leftAt", metadata, created_at as "createdAt", updated_at as "updatedAt"
`,
[auth.tenantId, classId, userId, memberType, status, jsonBodyValue(body.metadata), auth.userId],
);
await recordAudit(client, auth, 'tenant.class_member.upserted', 'tenant_class_members', result.rows[0].id, {
classId,
userId,
memberType,
status,
});
return result.rows[0];
});
return { item };
}
export async function removeTenantClassMemberRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'classes:write');
const body = await readJsonBody(ctx);
const classMemberId = requiredString(body, 'classMemberId');
const item = await transaction(async client => {
const result = await client.query(
`
update public.tenant_class_members
set status = 'removed',
left_at = now(),
updated_by = $3,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, class_id as "classId", user_id as "userId",
member_type as "memberType", status, left_at as "leftAt",
updated_at as "updatedAt"
`,
[auth.tenantId, classMemberId, auth.userId],
);
if (!result.rows[0]) throw new HttpError(404, 'Class member not found', 'CLASS_MEMBER_NOT_FOUND');
await recordAudit(client, auth, 'tenant.class_member.removed', 'tenant_class_members', classMemberId, {
classId: result.rows[0].classId,
userId: result.rows[0].userId,
});
return result.rows[0];
});
return { item };
}
export async function tenantStudentsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'students:read');
const limit = intParam(ctx, 'limit', 100, 500);
const keyword = stringParam(ctx, 'keyword');
const status = stringParam(ctx, 'status') || 'active';
const regionId = stringParam(ctx, 'regionId');
const classId = stringParam(ctx, 'classId');
const scopedIds = await scopedClassIds(auth);
if (classId) await ensureReadableClass(auth, classId);
if (!STUDENT_MEMBER_STATUSES.includes(status)) {
throw new HttpError(400, `Invalid student status: ${status}`, 'INVALID_STUDENT_STATUS');
}
const params: unknown[] = [auth.tenantId, status];
let classAggScopeSql = '';
if (scopedIds) {
params.push(scopedIds);
classAggScopeSql = `and tcm.class_id = any($${params.length}::uuid[])`;
}
const filters = ['tm.tenant_id = $1', `tm.role = 'student'`, 'tm.status = $2'];
if (keyword) {
params.push(`%${keyword}%`);
filters.push(`(u.username ilike $${params.length} or u.name ilike $${params.length} or u.phone ilike $${params.length} or u.email::text ilike $${params.length})`);
}
if (regionId) {
params.push(regionId);
filters.push(`sp.region_id = $${params.length}::uuid`);
}
if (classId) {
params.push(classId);
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 = $${params.length}::uuid
and scoped_cm.member_type = 'student'
and scoped_cm.status = 'active'
)`);
}
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'
)`);
}
params.push(limit);
const items = await query<Record<string, unknown>>(
`
with class_agg as (
select tcm.tenant_id, tcm.user_id,
jsonb_agg(
jsonb_build_object(
'classId', tc.id,
'className', tc.name,
'classCode', tc.code,
'memberType', tcm.member_type,
'joinedAt', tcm.joined_at
)
order by tc.sort_order asc, tc.created_at desc
) filter (where tcm.status = 'active') as classes
from public.tenant_class_members tcm
join public.tenant_classes tc on tc.tenant_id = tcm.tenant_id and tc.id = tcm.class_id
where tcm.tenant_id = $1 and tcm.member_type = 'student'
${classAggScopeSql}
group by tcm.tenant_id, tcm.user_id
)
select tm.id as "membershipId", tm.user_id as "userId", tm.status,
tm.created_at as "memberCreatedAt", tm.updated_at as "memberUpdatedAt",
u.username, u.email::text as email, u.phone, u.name,
u.avatar_url as "avatarUrl", u.primary_role as "primaryRole",
u.last_seen_at as "lastSeenAt",
sp.id as "profileId", sp.region_id as "regionId", r.name as "regionName",
sp.selected_school_id as "selectedSchoolId", s.name as "selectedSchoolName",
sp.selected_major_id as "selectedMajorId", m.name as "selectedMajorName",
sp.questions_answered_today as "questionsAnsweredToday",
sp.mastered_words_count as "masteredWordsCount",
sp.last_check_in_date as "lastCheckInDate",
sp.stats, sp.progress, sp.module_selections as "moduleSelections",
coalesce(ca.classes, '[]'::jsonb) as classes
from public.tenant_memberships tm
join public.platform_users u on u.id = tm.user_id
left join public.student_profiles sp on sp.tenant_id = tm.tenant_id and sp.user_id = tm.user_id
left join public.regions r on r.tenant_id = tm.tenant_id and r.id = sp.region_id
left join public.schools s on s.tenant_id = tm.tenant_id and s.id = sp.selected_school_id
left join public.majors m on m.tenant_id = tm.tenant_id and m.id = sp.selected_major_id
left join class_agg ca on ca.tenant_id = tm.tenant_id and ca.user_id = tm.user_id
where ${filters.join(' and ')}
order by tm.created_at desc
limit $${params.length}
`,
params,
);
return { items: items.map(item => maskStudentFields(auth, item)), scoped: scopedIds !== null };
}
export async function upsertTenantStudentRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'students:write');
const body = await readJsonBody(ctx);
const status = optionalChoice(body.status, STUDENT_MEMBER_STATUSES, 'active');
const item = await transaction(async client => {
const userId = await resolveOrCreateUser(client, body, 'student');
const regionId = nullableString(body.regionId);
const selectedSchoolId = nullableString(body.selectedSchoolId);
const selectedMajorId = nullableString(body.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, region_id, selected_school_id, selected_major_id,
stats, progress, module_selections
)
values ($1, $2, $3::uuid, $4::uuid, $5::uuid, $6::jsonb, $7::jsonb, $8::jsonb)
on conflict (tenant_id, user_id)
do update set 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 $6::jsonb = '{}'::jsonb then public.student_profiles.stats else excluded.stats end,
progress = case when $7::jsonb = '{}'::jsonb then public.student_profiles.progress else excluded.progress end,
module_selections = case when $8::jsonb = '{}'::jsonb then public.student_profiles.module_selections else excluded.module_selections end,
updated_at = now()
returning id as "profileId", tenant_id as "tenantId", user_id as "userId",
region_id as "regionId", selected_school_id as "selectedSchoolId",
selected_major_id as "selectedMajorId", stats, progress,
module_selections as "moduleSelections", updated_at as "updatedAt"
`,
[
auth.tenantId,
userId,
regionId,
selectedSchoolId,
selectedMajorId,
jsonBodyValue(body.stats),
jsonBodyValue(body.progress),
jsonBodyValue(body.moduleSelections),
],
);
await recordAudit(client, auth, 'tenant.student.upserted', 'student_profiles', profile.rows[0].profileId, {
userId,
status,
regionId,
selectedSchoolId,
selectedMajorId,
});
return profile.rows[0];
});
return { item };
}
export async function tenantTeachersRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'classes:read');
const limit = intParam(ctx, 'limit', 100, 500);
const keyword = stringParam(ctx, 'keyword');
const params: unknown[] = [auth.tenantId];
const filters = ['tm.tenant_id = $1', `tm.role = 'teacher'`, `tm.status = 'active'`];
if (keyword) {
params.push(`%${keyword}%`);
filters.push(`(u.username ilike $${params.length} or u.name ilike $${params.length} or u.phone ilike $${params.length})`);
}
if (!canReadAllClassScope(auth)) {
params.push(auth.userId);
filters.push(`tm.user_id = $${params.length}::uuid`);
}
params.push(limit);
const items = await query<Record<string, unknown>>(
`
select tm.id as "membershipId", tm.user_id as "userId", tm.status,
u.username, u.email::text as email, u.phone, u.name,
u.avatar_url as "avatarUrl", u.last_seen_at as "lastSeenAt",
coalesce(count(tcm.id) filter (where tcm.status = 'active'), 0)::int as "classCount"
from public.tenant_memberships tm
join public.platform_users u on u.id = tm.user_id
left join public.tenant_class_members tcm
on tcm.tenant_id = tm.tenant_id
and tcm.user_id = tm.user_id
and tcm.member_type in ('teacher', 'assistant', 'head_teacher')
where ${filters.join(' and ')}
group by tm.id, u.id
order by tm.created_at desc
limit $${params.length}
`,
params,
);
return { items };
}

View File

@@ -1,4 +1,15 @@
import type { RouteDefinition } from '../../core/router.js';
import {
disableTenantClassRoute,
removeTenantClassMemberRoute,
tenantClassesRoute,
tenantClassMembersRoute,
tenantStudentsRoute,
tenantTeachersRoute,
upsertTenantClassMemberRoute,
upsertTenantClassRoute,
upsertTenantStudentRoute,
} from './classes.js';
import {
activationCodesRoute,
announcementsAdminRoute,
@@ -39,6 +50,15 @@ export const tenantAdminRoutes: RouteDefinition[] = [
['GET', '/api/tenant-admin/role-templates', tenantRoleTemplatesRoute],
['PUT', '/api/tenant-admin/role-templates', upsertTenantRoleTemplateRoute],
['POST', '/api/tenant-admin/role-templates/disable', disableTenantRoleTemplateRoute],
['GET', '/api/tenant-admin/classes', tenantClassesRoute],
['PUT', '/api/tenant-admin/classes', upsertTenantClassRoute],
['POST', '/api/tenant-admin/classes/disable', disableTenantClassRoute],
['GET', '/api/tenant-admin/classes/members', tenantClassMembersRoute],
['PUT', '/api/tenant-admin/classes/members', upsertTenantClassMemberRoute],
['POST', '/api/tenant-admin/classes/members/remove', removeTenantClassMemberRoute],
['GET', '/api/tenant-admin/students', tenantStudentsRoute],
['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute],
['GET', '/api/tenant-admin/teachers', tenantTeachersRoute],
['GET', '/api/tenant-admin/overview', tenantOverviewRoute],
['PUT', '/api/tenant-admin/branding', updateTenantBrandingRoute],
['PUT', '/api/tenant-admin/settings', updateTenantSettingsRoute],