forked from wangziqi/gongxue-base
feat: add student crm push
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
|
||||
@@ -22,13 +23,33 @@ 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;
|
||||
const MAX_BULK_CRM_PUSH = 100;
|
||||
const CRM_ASSIGNABLE_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent'];
|
||||
const STUDENT_AVATAR_FIELD_KEYS = ['avatarUrl', 'avatar_url', 'avatar', 'headimgurl', 'headImgUrl', 'figureurl', 'figureurl_qq_1', 'figureurl_qq_2'];
|
||||
const STUDENT_PRIMARY_ROLE_FIELD_KEYS = ['primaryRole', 'primary_role'];
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
function nullableString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function limitedNullableString(value: unknown, maxLength: number, fieldName: string) {
|
||||
const candidate = nullableString(value);
|
||||
if (!candidate) return null;
|
||||
if (candidate.length > maxLength) {
|
||||
throw new HttpError(400, `${fieldName} is too long`, 'FIELD_TOO_LONG');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function requiredLimitedString(body: JsonBody, key: string, maxLength: number) {
|
||||
const value = requiredString(body, key);
|
||||
if (value.length > maxLength) {
|
||||
throw new HttpError(400, `${key} is too long`, 'FIELD_TOO_LONG');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectValue(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
@@ -80,7 +101,42 @@ 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));
|
||||
.filter(item => UUID_PATTERN.test(item));
|
||||
}
|
||||
|
||||
function strictUuidArrayValue(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');
|
||||
}
|
||||
|
||||
const items: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const item = typeof value[index] === 'string' ? value[index].trim() : '';
|
||||
if (!UUID_PATTERN.test(item)) {
|
||||
throw new HttpError(400, `${fieldName}[${index}] must be a uuid`, 'INVALID_UUID_FIELD');
|
||||
}
|
||||
const normalized = item.toLowerCase();
|
||||
if (!seen.has(normalized)) {
|
||||
seen.add(normalized);
|
||||
items.push(normalized);
|
||||
}
|
||||
}
|
||||
if (!items.length) throw new HttpError(400, `${fieldName} is required`, 'REQUIRED_FIELD');
|
||||
return items;
|
||||
}
|
||||
|
||||
function optionalTimestampString(value: unknown, fieldName: string) {
|
||||
const candidate = nullableString(value);
|
||||
if (!candidate) return null;
|
||||
const time = Date.parse(candidate);
|
||||
if (!Number.isFinite(time)) {
|
||||
throw new HttpError(400, `${fieldName} must be a valid datetime`, 'INVALID_DATETIME');
|
||||
}
|
||||
return new Date(time).toISOString();
|
||||
}
|
||||
|
||||
function classCodeValue(value: unknown) {
|
||||
@@ -353,6 +409,150 @@ async function ensureTenantMembership(
|
||||
);
|
||||
}
|
||||
|
||||
interface StudentCrmPushConfig {
|
||||
enabled: boolean;
|
||||
url: string | null;
|
||||
formName: string | null;
|
||||
examType: string | null;
|
||||
delaySec: number | null;
|
||||
}
|
||||
|
||||
async function loadCrmPushConfig(client: pg.PoolClient, tenantId: string) {
|
||||
const result = await client.query<StudentCrmPushConfig>(
|
||||
`
|
||||
select enabled, url, form_name as "formName", exam_type as "examType", delay_sec as "delaySec"
|
||||
from public.crm_config
|
||||
where tenant_id = $1
|
||||
limit 1
|
||||
`,
|
||||
[tenantId],
|
||||
);
|
||||
const config = result.rows[0] || null;
|
||||
if (!config?.enabled || !config.url) {
|
||||
throw new HttpError(409, 'CRM config is disabled or missing webhook URL', 'CRM_CONFIG_REQUIRED');
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function crmPushIdempotencyKey(inputKey: string | null, requestId: string, studentUserId: string) {
|
||||
const rawKey = inputKey || requestId;
|
||||
const normalized = rawKey.length <= 80 ? rawKey : createHash('sha256').update(rawKey).digest('hex');
|
||||
return `student_crm_push:${normalized}:${studentUserId}`;
|
||||
}
|
||||
|
||||
async function loadStudentCrmPayload(
|
||||
client: pg.PoolClient,
|
||||
auth: TenantAdminAuth,
|
||||
studentUserId: string,
|
||||
includePhone: boolean,
|
||||
) {
|
||||
const result = await client.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
|
||||
)
|
||||
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.user_id = $2::uuid
|
||||
and tcm.member_type = 'student'
|
||||
group by tcm.tenant_id, tcm.user_id
|
||||
)
|
||||
select u.id as "userId", u.username, u.name,
|
||||
case when $3::boolean then u.phone else null end as phone,
|
||||
u.email::text as email,
|
||||
coalesce(sp.avatar_preset, 'male') as "avatarPreset",
|
||||
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",
|
||||
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 tm.tenant_id = $1
|
||||
and tm.user_id = $2::uuid
|
||||
and tm.role = 'student'
|
||||
and tm.status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId, studentUserId, includePhone],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Student not found', 'STUDENT_NOT_FOUND');
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async function loadCrmAssigneePayload(client: pg.PoolClient, tenantId: string, userId: string | null) {
|
||||
if (!userId) return null;
|
||||
const result = await client.query<Record<string, unknown>>(
|
||||
`
|
||||
select u.id as "userId", u.username, u.name, tm.role
|
||||
from public.tenant_memberships tm
|
||||
join public.platform_users u on u.id = tm.user_id
|
||||
where tm.tenant_id = $1
|
||||
and tm.user_id = $2::uuid
|
||||
and tm.role = any($3::text[])
|
||||
and tm.status = 'active'
|
||||
order by case tm.role
|
||||
when 'tenant_owner' then 1
|
||||
when 'tenant_admin' then 2
|
||||
when 'tenant_operator' then 3
|
||||
when 'teacher' then 4
|
||||
when 'sales' then 5
|
||||
when 'agent' then 6
|
||||
else 9
|
||||
end
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId, CRM_ASSIGNABLE_ROLES],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function loadCrmClassPayload(client: pg.PoolClient, tenantId: string, classId: string | null) {
|
||||
if (!classId) return null;
|
||||
const result = await client.query<Record<string, unknown>>(
|
||||
`
|
||||
select id as "classId", code as "classCode", name as "className"
|
||||
from public.tenant_classes
|
||||
where tenant_id = $1 and id = $2::uuid
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, classId],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function ensureStudentInClass(client: pg.PoolClient, tenantId: string, studentUserId: string, classId: string | null) {
|
||||
if (!classId) return;
|
||||
const result = await client.query<{ id: string }>(
|
||||
`
|
||||
select id
|
||||
from public.tenant_class_members
|
||||
where tenant_id = $1
|
||||
and class_id = $2::uuid
|
||||
and user_id = $3::uuid
|
||||
and member_type = 'student'
|
||||
and status = 'active'
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, classId, studentUserId],
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
throw new HttpError(400, 'Student is not an active member of the selected class', 'STUDENT_CLASS_REQUIRED');
|
||||
}
|
||||
}
|
||||
|
||||
function membershipRoleForClassMember(memberType: string) {
|
||||
return memberType === 'student' ? 'student' : 'teacher';
|
||||
}
|
||||
@@ -1267,3 +1467,209 @@ export async function upsertTenantStudentFollowupRoute(ctx: RequestContext) {
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function pushTenantStudentsToCrmRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'crm:write');
|
||||
requireTenantPermission(auth, 'students:read');
|
||||
requireTenantPermission(auth, 'students:followups:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
|
||||
const studentUserIds = strictUuidArrayValue(body.studentUserIds || body.userIds, 'studentUserIds', MAX_BULK_CRM_PUSH);
|
||||
const classId = nullableString(body.classId);
|
||||
if (classId) await ensureReadableClass(auth, classId);
|
||||
|
||||
const source = limitedNullableString(body.source, 80, 'source') || 'manual_batch';
|
||||
const title = requiredLimitedString(body, 'title', 120);
|
||||
const message = limitedNullableString(body.message ?? body.description, 1000, 'message');
|
||||
const followupType = optionalChoice(body.followupType, STUDENT_FOLLOWUP_TYPES, 'sales');
|
||||
const priority = optionalChoice(body.priority, STUDENT_FOLLOWUP_PRIORITIES, 'normal');
|
||||
const assignedToUserId = nullableString(body.assignedToUserId);
|
||||
const dueAt = optionalTimestampString(body.dueAt, 'dueAt');
|
||||
const metadata = objectValue(body.metadata);
|
||||
const requestId = randomUUID();
|
||||
const idempotencyInput = limitedNullableString(body.idempotencyKey, 160, 'idempotencyKey');
|
||||
const includePhone = canSeeStudentPhone(auth);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
const config = await loadCrmPushConfig(client, auth.tenantId);
|
||||
if (classId) await ensureTenantClass(client, auth.tenantId, classId);
|
||||
await ensureUserTenantMembership(client, auth.tenantId, assignedToUserId, CRM_ASSIGNABLE_ROLES, 'ASSIGNEE_NOT_FOUND');
|
||||
const assignee = await loadCrmAssigneePayload(client, auth.tenantId, assignedToUserId);
|
||||
const classPayload = await loadCrmClassPayload(client, auth.tenantId, classId);
|
||||
const delaySeconds = Math.max(0, Number(config.delaySec ?? 60));
|
||||
const items: unknown[] = [];
|
||||
const errors: unknown[] = [];
|
||||
|
||||
for (let index = 0; index < studentUserIds.length; index += 1) {
|
||||
const studentUserId = studentUserIds[index];
|
||||
try {
|
||||
await ensureStudentInScope(auth, studentUserId);
|
||||
await ensureStudentInClass(client, auth.tenantId, studentUserId, classId);
|
||||
const student = await loadStudentCrmPayload(client, auth, studentUserId, includePhone);
|
||||
const idempotencyKey = crmPushIdempotencyKey(idempotencyInput, requestId, studentUserId);
|
||||
const followupMetadata = {
|
||||
...metadata,
|
||||
crmPush: {
|
||||
requestId,
|
||||
idempotencyKey,
|
||||
source,
|
||||
},
|
||||
};
|
||||
|
||||
let followup = (await client.query<{
|
||||
id: string;
|
||||
studentUserId: string;
|
||||
assignedToUserId: string | null;
|
||||
classId: string | null;
|
||||
title: string;
|
||||
dueAt: string | null;
|
||||
createdAt: string;
|
||||
}>(
|
||||
`
|
||||
select id, student_user_id as "studentUserId",
|
||||
assigned_to_user_id as "assignedToUserId", class_id as "classId",
|
||||
title, due_at as "dueAt", created_at as "createdAt"
|
||||
from public.tenant_student_followups
|
||||
where tenant_id = $1
|
||||
and student_user_id = $2::uuid
|
||||
and metadata->'crmPush'->>'idempotencyKey' = $3
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId, studentUserId, idempotencyKey],
|
||||
)).rows[0];
|
||||
|
||||
if (!followup) {
|
||||
followup = (await client.query(
|
||||
`
|
||||
insert into public.tenant_student_followups (
|
||||
tenant_id, student_user_id, assigned_to_user_id, class_id,
|
||||
title, description, followup_type, priority, status, due_at,
|
||||
metadata, created_by, updated_by
|
||||
)
|
||||
values (
|
||||
$1, $2::uuid, $3::uuid, $4::uuid,
|
||||
$5, $6, $7, $8, 'open', $9::timestamptz,
|
||||
$10::jsonb, $11, $11
|
||||
)
|
||||
returning id, student_user_id as "studentUserId",
|
||||
assigned_to_user_id as "assignedToUserId", class_id as "classId",
|
||||
title, due_at as "dueAt", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
studentUserId,
|
||||
assignedToUserId,
|
||||
classId,
|
||||
title,
|
||||
message,
|
||||
followupType,
|
||||
priority,
|
||||
dueAt,
|
||||
JSON.stringify(followupMetadata),
|
||||
auth.userId,
|
||||
],
|
||||
)).rows[0];
|
||||
}
|
||||
|
||||
const payload = {
|
||||
eventType: 'student.crm_push',
|
||||
formName: config.formName || '刷题题库',
|
||||
examType: config.examType || '成人本科',
|
||||
source,
|
||||
title,
|
||||
message,
|
||||
student,
|
||||
assignee,
|
||||
class: classPayload,
|
||||
followup: {
|
||||
id: followup.id,
|
||||
title: followup.title,
|
||||
followupType,
|
||||
priority,
|
||||
dueAt: followup.dueAt,
|
||||
},
|
||||
actor: {
|
||||
userId: auth.userId,
|
||||
role: auth.role,
|
||||
},
|
||||
metadata,
|
||||
};
|
||||
|
||||
const queued = await client.query<{
|
||||
id: string;
|
||||
status: string;
|
||||
scheduledAt: string | null;
|
||||
idempotencyKey: string;
|
||||
}>(
|
||||
`
|
||||
insert into public.crm_webhook_queue (
|
||||
tenant_id, record_id, status, scheduled_at, next_attempt_at, lead_id,
|
||||
source, payload, idempotency_key, target_url
|
||||
)
|
||||
values ($1, $2, 'pending', now() + ($3::int * interval '1 second'), null, null, $4, $5::jsonb, $6, $7)
|
||||
on conflict (tenant_id, idempotency_key) where idempotency_key is not null
|
||||
do update set status = case
|
||||
when public.crm_webhook_queue.status = 'sent' then public.crm_webhook_queue.status
|
||||
else 'pending'
|
||||
end,
|
||||
scheduled_at = excluded.scheduled_at,
|
||||
next_attempt_at = null,
|
||||
payload = excluded.payload,
|
||||
target_url = excluded.target_url,
|
||||
updated_at = now()
|
||||
returning id, status, scheduled_at as "scheduledAt", idempotency_key as "idempotencyKey"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
studentUserId,
|
||||
delaySeconds,
|
||||
'tenant.student.crm_push',
|
||||
JSON.stringify(payload),
|
||||
idempotencyKey,
|
||||
config.url,
|
||||
],
|
||||
);
|
||||
|
||||
items.push({
|
||||
index,
|
||||
studentUserId,
|
||||
followupId: followup.id,
|
||||
queueId: queued.rows[0].id,
|
||||
queueStatus: queued.rows[0].status,
|
||||
scheduledAt: queued.rows[0].scheduledAt,
|
||||
});
|
||||
} catch (error) {
|
||||
errors.push({
|
||||
index,
|
||||
studentUserId,
|
||||
code: error instanceof HttpError ? error.code : 'STUDENT_CRM_PUSH_ITEM_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Student CRM push item failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(client, auth, 'tenant.students.crm_pushed', 'crm_webhook_queue', null, {
|
||||
requestId,
|
||||
total: studentUserIds.length,
|
||||
successCount: items.length,
|
||||
errorCount: errors.length,
|
||||
source,
|
||||
followupType,
|
||||
priority,
|
||||
assignedToUserId,
|
||||
classId,
|
||||
});
|
||||
|
||||
return {
|
||||
requestId,
|
||||
total: studentUserIds.length,
|
||||
successCount: items.length,
|
||||
errorCount: errors.length,
|
||||
items,
|
||||
errors,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
tenantStudentNotesRoute,
|
||||
tenantStudentsRoute,
|
||||
tenantTeachersRoute,
|
||||
pushTenantStudentsToCrmRoute,
|
||||
updateTenantStudentStatusRoute,
|
||||
upsertTenantStudentFollowupRoute,
|
||||
upsertTenantStudentNoteRoute,
|
||||
@@ -97,6 +98,7 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['PUT', '/api/tenant-admin/students', upsertTenantStudentRoute],
|
||||
['POST', '/api/tenant-admin/students/bulk-upsert', bulkUpsertTenantStudentsRoute],
|
||||
['POST', '/api/tenant-admin/students/status', updateTenantStudentStatusRoute],
|
||||
['POST', '/api/tenant-admin/students/crm-push', pushTenantStudentsToCrmRoute],
|
||||
['GET', '/api/tenant-admin/students/notes', tenantStudentNotesRoute],
|
||||
['PUT', '/api/tenant-admin/students/notes', upsertTenantStudentNoteRoute],
|
||||
['GET', '/api/tenant-admin/students/followups', tenantStudentFollowupsRoute],
|
||||
|
||||
Reference in New Issue
Block a user