forked from wangziqi/gongxue-base
feat: add tenant class student scopes
This commit is contained in:
@@ -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: '角色模板查看' },
|
||||
|
||||
724
apps/api/src/features/tenant-admin/classes.ts
Normal file
724
apps/api/src/features/tenant-admin/classes.ts
Normal 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 };
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user