forked from wangziqi/gongxue-base
feat: add tenant student operations
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:*', '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],
|
||||
|
||||
Reference in New Issue
Block a user