forked from wangziqi/gongxue-base
feat: add feedback checkins exam dates
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
announcementsRoute,
|
||||
bannersRoute,
|
||||
categoriesRoute,
|
||||
examDatesRoute,
|
||||
faqsRoute,
|
||||
handbookChaptersRoute,
|
||||
handbookEntriesRoute,
|
||||
@@ -55,5 +56,6 @@ export const catalogRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/catalog/announcements', announcementsRoute],
|
||||
['GET', '/api/catalog/products', productsRoute],
|
||||
['GET', '/api/catalog/timelines', timelinesRoute],
|
||||
['GET', '/api/catalog/exam-dates', examDatesRoute],
|
||||
['GET', '/api/catalog/svip-plans', svipPlansRoute],
|
||||
];
|
||||
|
||||
@@ -2,6 +2,22 @@ import type { RequestContext } from '../../core/http.js';
|
||||
import { query } from '../../core/db.js';
|
||||
import { intParam, tenantIdFrom } from '../../core/request.js';
|
||||
|
||||
function dateOnly(value: unknown) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
|
||||
if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
function daysUntil(dateValue: unknown, today: Date) {
|
||||
const dateText = dateOnly(dateValue);
|
||||
if (!dateText) return null;
|
||||
const target = new Date(`${dateText}T00:00:00.000Z`);
|
||||
if (Number.isNaN(target.getTime())) return null;
|
||||
const current = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
|
||||
return Math.ceil((target.getTime() - current.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
export async function regionsRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
|
||||
@@ -566,6 +582,50 @@ export async function timelinesRoute(ctx: RequestContext) {
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function examDatesRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const schoolId = ctx.url.searchParams.get('schoolId');
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`(region_id = $${params.length}::uuid or region_id is null)`);
|
||||
}
|
||||
if (schoolId) {
|
||||
params.push(schoolId);
|
||||
filters.push(`(school_id = $${params.length}::uuid or school_id is null)`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const today = new Date();
|
||||
const items = await query<{
|
||||
examDate: string | null;
|
||||
} & Record<string, unknown>>(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId", school_id as "schoolId",
|
||||
exam_name as "examName", exam_date::text as "examDate", exam_type as "examType",
|
||||
description, metadata, sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.exam_dates
|
||||
where ${filters.join(' and ')}
|
||||
order by exam_date asc nulls last, sort_order asc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return {
|
||||
items: items.map(item => {
|
||||
return {
|
||||
...item,
|
||||
daysLeft: daysUntil(item.examDate, today),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function svipPlansRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import type { RouteDefinition } from '../../core/router.js';
|
||||
import { profileMeRoute, updateProfileMeRoute } from './routes.js';
|
||||
import {
|
||||
checkInRoute,
|
||||
examCountdownRoute,
|
||||
feedbacksRoute,
|
||||
profileMeRoute,
|
||||
scoreEventsRoute,
|
||||
submitFeedbackRoute,
|
||||
updateProfileMeRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const profileRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/profile/me', profileMeRoute],
|
||||
['PATCH', '/api/profile/me', updateProfileMeRoute],
|
||||
['POST', '/api/profile/check-in', checkInRoute],
|
||||
['GET', '/api/profile/score-events', scoreEventsRoute],
|
||||
['GET', '/api/profile/feedbacks', feedbacksRoute],
|
||||
['POST', '/api/profile/feedbacks', submitFeedbackRoute],
|
||||
['GET', '/api/profile/exam-countdowns', examCountdownRoute],
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, readJsonBody, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
|
||||
type JsonMap = Record<string, unknown>;
|
||||
|
||||
@@ -39,6 +39,40 @@ function jsonArrayBodyValue(value: unknown) {
|
||||
return JSON.stringify(Array.isArray(value) ? value : []);
|
||||
}
|
||||
|
||||
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 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 toDateOnly(value: unknown) {
|
||||
if (!value) return null;
|
||||
if (value instanceof Date && !Number.isNaN(value.getTime())) return value.toISOString().slice(0, 10);
|
||||
if (typeof value === 'string' && value.trim()) return value.trim().slice(0, 10);
|
||||
return null;
|
||||
}
|
||||
|
||||
function daysBetween(dateValue: unknown, today: Date) {
|
||||
const dateText = toDateOnly(dateValue);
|
||||
if (!dateText) return null;
|
||||
const target = new Date(`${dateText}T00:00:00.000Z`);
|
||||
if (Number.isNaN(target.getTime())) return null;
|
||||
const current = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
|
||||
return Math.ceil((target.getTime() - current.getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
const FEEDBACK_TYPES = ['question_error', 'content_error', 'video_error', 'asset_error', 'system_bug', 'suggestion', 'other'];
|
||||
|
||||
export async function profileMeRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
@@ -257,3 +291,283 @@ export async function updateProfileMeRoute(ctx: RequestContext) {
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function checkInRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const existing = await client.query<{
|
||||
lastCheckInDate: string | null;
|
||||
score: number;
|
||||
stats: Record<string, unknown>;
|
||||
}>(
|
||||
`
|
||||
select sp.last_check_in_date as "lastCheckInDate", u.score, sp.stats
|
||||
from public.student_profiles sp
|
||||
join public.platform_users u on u.id = sp.user_id
|
||||
where sp.tenant_id = $1 and sp.user_id = $2
|
||||
limit 1
|
||||
for update of sp, u
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
const profile = existing.rows[0];
|
||||
if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
|
||||
if (profile.lastCheckInDate === today) {
|
||||
return {
|
||||
checkedIn: false,
|
||||
alreadyCheckedIn: true,
|
||||
pointsAdded: 0,
|
||||
score: Number(profile.score || 0),
|
||||
lastCheckInDate: today,
|
||||
};
|
||||
}
|
||||
|
||||
const yesterday = new Date();
|
||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
||||
const yesterdayText = yesterday.toISOString().slice(0, 10);
|
||||
const stats = objectValue(profile.stats);
|
||||
const previousStreak = Number(stats.checkInStreak || 0);
|
||||
const streak = profile.lastCheckInDate === yesterdayText ? previousStreak + 1 : 1;
|
||||
const pointsAdded = 10 + Math.min(Math.max(streak - 1, 0), 6);
|
||||
const balanceAfter = Number(profile.score || 0) + pointsAdded;
|
||||
|
||||
const nextStats = {
|
||||
...stats,
|
||||
checkInStreak: streak,
|
||||
lastCheckInPoints: pointsAdded,
|
||||
};
|
||||
|
||||
const ledger = await client.query(
|
||||
`
|
||||
insert into public.user_score_events (
|
||||
tenant_id, user_id, event_type, points, balance_after,
|
||||
source_type, idempotency_key, metadata
|
||||
)
|
||||
values ($1, $2, 'check_in', $3, $4, 'student_profiles', $5, $6::jsonb)
|
||||
on conflict (tenant_id, idempotency_key) do nothing
|
||||
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
|
||||
source_type as "sourceType", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
userId,
|
||||
pointsAdded,
|
||||
balanceAfter,
|
||||
`check_in:${userId}:${today}`,
|
||||
JSON.stringify({ checkInDate: today, streak }),
|
||||
],
|
||||
);
|
||||
|
||||
if (!ledger.rows[0]) {
|
||||
return {
|
||||
checkedIn: false,
|
||||
alreadyCheckedIn: true,
|
||||
pointsAdded: 0,
|
||||
score: Number(profile.score || 0),
|
||||
lastCheckInDate: today,
|
||||
};
|
||||
}
|
||||
|
||||
const updatedUser = await client.query<{ score: number }>(
|
||||
`
|
||||
update public.platform_users
|
||||
set score = score + $2,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning score
|
||||
`,
|
||||
[userId, pointsAdded],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.student_profiles
|
||||
set last_check_in_date = $3::date,
|
||||
stats = $4::jsonb,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and user_id = $2
|
||||
`,
|
||||
[tenantId, userId, today, JSON.stringify(nextStats)],
|
||||
);
|
||||
|
||||
return {
|
||||
checkedIn: true,
|
||||
alreadyCheckedIn: false,
|
||||
pointsAdded,
|
||||
streak,
|
||||
score: updatedUser.rows[0]?.score || balanceAfter,
|
||||
lastCheckInDate: today,
|
||||
ledger: {
|
||||
...ledger.rows[0],
|
||||
balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function scoreEventsRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, event_type as "eventType", points, balance_after as "balanceAfter",
|
||||
source_type as "sourceType", source_id as "sourceId",
|
||||
metadata, created_at as "createdAt"
|
||||
from public.user_score_events
|
||||
where tenant_id = $1 and user_id = $2
|
||||
order by created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[tenantId, userId, limit],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function feedbacksRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
const status = stringParam(ctx, 'status');
|
||||
const params: unknown[] = [tenantId, userId];
|
||||
const filters = ['r.tenant_id = $1', 'r.user_id = $2'];
|
||||
if (status) {
|
||||
params.push(status);
|
||||
filters.push(`r.status = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select r.id, r.question_id as "questionId", q.type as "questionType",
|
||||
r.type, r.category, r.title, r.description, r.status, r.priority,
|
||||
r.resolution, r.handled_by as "handledBy", r.handled_at as "handledAt",
|
||||
r.attachments, r.metadata, r.created_at as "createdAt", r.updated_at as "updatedAt"
|
||||
from public.reports r
|
||||
left join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
|
||||
where ${filters.join(' and ')}
|
||||
order by r.created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function submitFeedbackRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const questionId = nullableString(body.questionId);
|
||||
const type = optionalChoice(body.type, FEEDBACK_TYPES, 'question_error');
|
||||
const description = requiredString(body, 'description');
|
||||
const attachments = Array.isArray(body.attachments) ? body.attachments : [];
|
||||
|
||||
const item = await transaction(async client => {
|
||||
if (questionId) {
|
||||
const question = await client.query<{ id: string }>(
|
||||
'select id from public.questions where tenant_id = $1 and id = $2 and status <> \'archived\' limit 1',
|
||||
[tenantId, questionId],
|
||||
);
|
||||
if (!question.rows[0]) throw new HttpError(404, 'Question not found for this tenant', 'QUESTION_NOT_FOUND');
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.reports (
|
||||
tenant_id, question_id, user_id, type, category, title, description,
|
||||
status, priority, contact, attachments, metadata
|
||||
)
|
||||
values ($1, $2::uuid, $3, $4, $5, $6, $7, 'pending', $8, $9, $10::jsonb, $11::jsonb)
|
||||
returning id, question_id as "questionId", user_id as "userId", type, category,
|
||||
title, description, status, priority, contact, attachments, metadata,
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
tenantId,
|
||||
questionId,
|
||||
userId,
|
||||
type,
|
||||
nullableString(body.category),
|
||||
nullableString(body.title),
|
||||
description,
|
||||
optionalChoice(body.priority, ['low', 'normal', 'high', 'urgent'], 'normal'),
|
||||
nullableString(body.contact),
|
||||
JSON.stringify(attachments),
|
||||
JSON.stringify(objectValue(body.metadata)),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.report_status_events (tenant_id, report_id, from_status, to_status, note, actor_user_id)
|
||||
values ($1, $2, null, 'pending', 'student submitted feedback', $3)
|
||||
`,
|
||||
[tenantId, result.rows[0].id, userId],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function examCountdownRoute(ctx: RequestContext) {
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 5, 20);
|
||||
|
||||
const profile = await queryOne<{
|
||||
regionId: string | null;
|
||||
selectedSchoolId: string | null;
|
||||
}>(
|
||||
`
|
||||
select region_id as "regionId", selected_school_id as "selectedSchoolId"
|
||||
from public.student_profiles
|
||||
where tenant_id = $1 and user_id = $2
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, userId],
|
||||
);
|
||||
if (!profile) throw new HttpError(404, 'Student profile not found', 'PROFILE_NOT_FOUND');
|
||||
|
||||
const items = await query<{
|
||||
id: string;
|
||||
examName: string;
|
||||
examDate: string | null;
|
||||
} & Record<string, unknown>>(
|
||||
`
|
||||
select id, region_id as "regionId", school_id as "schoolId",
|
||||
exam_name as "examName", exam_date::text as "examDate",
|
||||
exam_type as "examType", description, metadata,
|
||||
sort_order as "order", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.exam_dates
|
||||
where tenant_id = $1
|
||||
and is_active = true
|
||||
and ($2::uuid is null or region_id is null or region_id = $2::uuid)
|
||||
and ($3::uuid is null or school_id is null or school_id = $3::uuid)
|
||||
order by exam_date asc nulls last, sort_order asc
|
||||
limit $4
|
||||
`,
|
||||
[tenantId, profile.regionId, profile.selectedSchoolId, limit],
|
||||
);
|
||||
|
||||
const today = new Date();
|
||||
return {
|
||||
items: items.map(item => ({
|
||||
...item,
|
||||
daysLeft: daysBetween(item.examDate, today),
|
||||
})),
|
||||
target: {
|
||||
regionId: profile.regionId,
|
||||
schoolId: profile.selectedSchoolId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -102,6 +102,8 @@ export function tenantPermissionCatalog() {
|
||||
{ key: 'referral:write', label: '客资归属管理' },
|
||||
{ key: 'crm:read', label: 'CRM 队列查看' },
|
||||
{ key: 'crm:write', label: 'CRM 入队和重试' },
|
||||
{ key: 'feedback:read', label: '反馈查看' },
|
||||
{ key: 'feedback:write', label: '反馈处理' },
|
||||
{ key: 'classes:read', label: '班级查看' },
|
||||
{ key: 'classes:write', label: '班级管理' },
|
||||
{ key: 'students:read', label: '学生查看' },
|
||||
|
||||
@@ -17,6 +17,13 @@ import {
|
||||
upsertTenantClassRoute,
|
||||
upsertTenantStudentRoute,
|
||||
} from './classes.js';
|
||||
import {
|
||||
tenantExamDatesRoute,
|
||||
tenantFeedbackEventsRoute,
|
||||
tenantFeedbacksRoute,
|
||||
updateTenantFeedbackStatusRoute,
|
||||
upsertTenantExamDateRoute,
|
||||
} from './operations.js';
|
||||
import {
|
||||
activationCodesRoute,
|
||||
announcementsAdminRoute,
|
||||
@@ -90,6 +97,11 @@ export const tenantAdminRoutes: RouteDefinition[] = [
|
||||
['PUT', '/api/tenant-admin/faqs', upsertFaqRoute],
|
||||
['GET', '/api/tenant-admin/announcements', announcementsAdminRoute],
|
||||
['PUT', '/api/tenant-admin/announcements', upsertAnnouncementRoute],
|
||||
['GET', '/api/tenant-admin/exam-dates', tenantExamDatesRoute],
|
||||
['PUT', '/api/tenant-admin/exam-dates', upsertTenantExamDateRoute],
|
||||
['GET', '/api/tenant-admin/feedbacks', tenantFeedbacksRoute],
|
||||
['POST', '/api/tenant-admin/feedbacks/status', updateTenantFeedbackStatusRoute],
|
||||
['GET', '/api/tenant-admin/feedbacks/events', tenantFeedbackEventsRoute],
|
||||
['GET', '/api/tenant-admin/code-batches', codeBatchesRoute],
|
||||
['PUT', '/api/tenant-admin/code-batches', upsertCodeBatchRoute],
|
||||
['GET', '/api/tenant-admin/activation-codes', activationCodesRoute],
|
||||
|
||||
369
apps/api/src/features/tenant-admin/operations.ts
Normal file
369
apps/api/src/features/tenant-admin/operations.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
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 {
|
||||
requireTenantAdmin,
|
||||
requireTenantPermission,
|
||||
type TenantAdminAuth,
|
||||
} from './auth.js';
|
||||
|
||||
type JsonBody = Record<string, unknown>;
|
||||
|
||||
const REPORT_STATUSES = ['pending', 'accepted', 'rejected', 'resolved', 'closed'];
|
||||
const REPORT_PRIORITIES = ['low', 'normal', 'high', 'urgent'];
|
||||
|
||||
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 intValue(value: unknown, fallback: number) {
|
||||
const numberValue = Number(value ?? fallback);
|
||||
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)) {
|
||||
throw new HttpError(400, `Invalid value: ${candidate}`, 'INVALID_FIELD_VALUE');
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
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 ensureTenantReference(
|
||||
client: pg.PoolClient,
|
||||
tableName: 'regions' | 'schools',
|
||||
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);
|
||||
}
|
||||
|
||||
export async function tenantExamDatesRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'marketing:read');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const isActive = ctx.url.searchParams.get('isActive');
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
if (regionId) {
|
||||
params.push(regionId);
|
||||
filters.push(`region_id = $${params.length}::uuid`);
|
||||
}
|
||||
if (schoolId) {
|
||||
params.push(schoolId);
|
||||
filters.push(`school_id = $${params.length}::uuid`);
|
||||
}
|
||||
if (isActive === 'true' || isActive === 'false') {
|
||||
params.push(isActive === 'true');
|
||||
filters.push(`is_active = $${params.length}`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", region_id as "regionId", school_id as "schoolId",
|
||||
exam_name as "examName", exam_date as "examDate", exam_type as "examType",
|
||||
description, metadata, sort_order as "sortOrder", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
from public.exam_dates
|
||||
where ${filters.join(' and ')}
|
||||
order by exam_date asc nulls last, sort_order asc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertTenantExamDateRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'marketing:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const regionId = nullableString(body.regionId);
|
||||
const schoolId = nullableString(body.schoolId);
|
||||
await ensureTenantReference(client, 'regions', auth.tenantId, regionId, 'REGION_NOT_FOUND');
|
||||
await ensureTenantReference(client, 'schools', auth.tenantId, schoolId, 'SCHOOL_NOT_FOUND');
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.exam_dates (
|
||||
id, tenant_id, region_id, school_id, legacy_id, exam_name, exam_date,
|
||||
exam_type, description, metadata, sort_order, is_active
|
||||
)
|
||||
values (
|
||||
coalesce($2::uuid, gen_random_uuid()), $1, $3::uuid, $4::uuid, $5, $6, $7::date,
|
||||
$8, $9, $10::jsonb, $11, $12
|
||||
)
|
||||
on conflict (id)
|
||||
do update set region_id = excluded.region_id,
|
||||
school_id = excluded.school_id,
|
||||
legacy_id = coalesce(excluded.legacy_id, public.exam_dates.legacy_id),
|
||||
exam_name = excluded.exam_name,
|
||||
exam_date = excluded.exam_date,
|
||||
exam_type = excluded.exam_type,
|
||||
description = excluded.description,
|
||||
metadata = excluded.metadata,
|
||||
sort_order = excluded.sort_order,
|
||||
is_active = excluded.is_active,
|
||||
updated_at = now()
|
||||
where public.exam_dates.tenant_id = excluded.tenant_id
|
||||
returning id, legacy_id as "legacyId", region_id as "regionId", school_id as "schoolId",
|
||||
exam_name as "examName", exam_date as "examDate", exam_type as "examType",
|
||||
description, metadata, sort_order as "sortOrder", is_active as "isActive",
|
||||
created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
nullableString(body.id),
|
||||
regionId,
|
||||
schoolId,
|
||||
nullableString(body.legacyId),
|
||||
requiredString(body, 'examName'),
|
||||
nullableString(body.examDate),
|
||||
nullableString(body.examType),
|
||||
nullableString(body.description),
|
||||
JSON.stringify(objectValue(body.metadata)),
|
||||
intValue(body.sortOrder, 0),
|
||||
boolValue(body.isActive, true),
|
||||
],
|
||||
);
|
||||
if (!result.rows[0]) throw new HttpError(404, 'Exam date not found for this tenant', 'EXAM_DATE_NOT_FOUND');
|
||||
await recordAudit(client, auth, 'tenant.exam_date.upserted', 'exam_dates', result.rows[0].id, {
|
||||
examName: result.rows[0].examName,
|
||||
examDate: result.rows[0].examDate,
|
||||
});
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function tenantFeedbacksRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'feedback:read');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const status = stringParam(ctx, 'status');
|
||||
const type = stringParam(ctx, 'type');
|
||||
const questionId = stringParam(ctx, 'questionId');
|
||||
const userId = stringParam(ctx, 'userId');
|
||||
const params: unknown[] = [auth.tenantId];
|
||||
const filters = ['r.tenant_id = $1'];
|
||||
if (status) {
|
||||
if (!REPORT_STATUSES.includes(status)) throw new HttpError(400, `Invalid report status: ${status}`, 'INVALID_REPORT_STATUS');
|
||||
params.push(status);
|
||||
filters.push(`r.status = $${params.length}`);
|
||||
}
|
||||
if (type) {
|
||||
params.push(type);
|
||||
filters.push(`r.type = $${params.length}`);
|
||||
}
|
||||
if (questionId) {
|
||||
params.push(questionId);
|
||||
filters.push(`r.question_id = $${params.length}::uuid`);
|
||||
}
|
||||
if (userId) {
|
||||
params.push(userId);
|
||||
filters.push(`r.user_id = $${params.length}::uuid`);
|
||||
}
|
||||
params.push(limit);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select r.id, r.question_id as "questionId", q.type as "questionType",
|
||||
r.user_id as "userId", u.name as "userName", u.phone as "userPhone",
|
||||
r.type, r.category, r.title, r.description, r.status, r.priority,
|
||||
r.contact, r.attachments, r.metadata, r.resolution,
|
||||
r.handled_by as "handledBy", handler.name as "handledByName",
|
||||
r.handled_at as "handledAt", r.created_at as "createdAt", r.updated_at as "updatedAt"
|
||||
from public.reports r
|
||||
left join public.questions q on q.tenant_id = r.tenant_id and q.id = r.question_id
|
||||
left join public.platform_users u on u.id = r.user_id
|
||||
left join public.platform_users handler on handler.id = r.handled_by
|
||||
where ${filters.join(' and ')}
|
||||
order by case r.priority
|
||||
when 'urgent' then 1
|
||||
when 'high' then 2
|
||||
when 'normal' then 3
|
||||
else 4
|
||||
end, r.created_at desc
|
||||
limit $${params.length}
|
||||
`,
|
||||
params,
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function tenantFeedbackEventsRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'feedback:read');
|
||||
const reportId = stringParam(ctx, 'reportId');
|
||||
if (!reportId) throw new HttpError(400, 'reportId is required', 'REPORT_ID_REQUIRED');
|
||||
const items = await query(
|
||||
`
|
||||
select e.id, e.report_id as "reportId", e.from_status as "fromStatus",
|
||||
e.to_status as "toStatus", e.note, e.actor_user_id as "actorUserId",
|
||||
actor.name as "actorName", e.metadata, e.created_at as "createdAt"
|
||||
from public.report_status_events e
|
||||
left join public.platform_users actor on actor.id = e.actor_user_id
|
||||
where e.tenant_id = $1 and e.report_id = $2
|
||||
order by e.created_at asc
|
||||
`,
|
||||
[auth.tenantId, reportId],
|
||||
);
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function updateTenantFeedbackStatusRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantAdmin(ctx);
|
||||
requireTenantPermission(auth, 'feedback:write');
|
||||
const body = await readJsonBody(ctx);
|
||||
const reportId = requiredString(body, 'reportId');
|
||||
const nextStatus = optionalChoice(body.status, REPORT_STATUSES, 'accepted');
|
||||
const rewardPoints = Math.max(0, intValue(body.rewardPoints, 0));
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const current = await client.query<{ status: string; userId: string | null }>(
|
||||
`
|
||||
select status, user_id as "userId"
|
||||
from public.reports
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
`,
|
||||
[auth.tenantId, reportId],
|
||||
);
|
||||
if (!current.rows[0]) throw new HttpError(404, 'Feedback report not found', 'REPORT_NOT_FOUND');
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
update public.reports
|
||||
set status = $3,
|
||||
priority = $4,
|
||||
resolution = coalesce($5, resolution),
|
||||
handled_by = $6,
|
||||
handled_at = now(),
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
returning id, question_id as "questionId", user_id as "userId", type,
|
||||
title, description, status, priority, resolution,
|
||||
handled_by as "handledBy", handled_at as "handledAt",
|
||||
updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
reportId,
|
||||
nextStatus,
|
||||
optionalChoice(body.priority, REPORT_PRIORITIES, 'normal'),
|
||||
nullableString(body.resolution),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.report_status_events (
|
||||
tenant_id, report_id, from_status, to_status, note, actor_user_id, metadata
|
||||
)
|
||||
values ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
reportId,
|
||||
current.rows[0].status,
|
||||
nextStatus,
|
||||
nullableString(body.note),
|
||||
auth.userId,
|
||||
JSON.stringify(objectValue(body.metadata)),
|
||||
],
|
||||
);
|
||||
|
||||
let reward = null;
|
||||
if (rewardPoints > 0 && current.rows[0].userId) {
|
||||
const currentScore = await client.query<{ score: number }>(
|
||||
'select score from public.platform_users where id = $1 for update',
|
||||
[current.rows[0].userId],
|
||||
);
|
||||
const balanceAfter = Number(currentScore.rows[0]?.score || 0) + rewardPoints;
|
||||
const ledger = await client.query(
|
||||
`
|
||||
insert into public.user_score_events (
|
||||
tenant_id, user_id, event_type, points, balance_after,
|
||||
source_type, source_id, idempotency_key, metadata
|
||||
)
|
||||
values ($1, $2, 'feedback_reward', $3, $4, 'reports', $5, $6, $7::jsonb)
|
||||
on conflict (tenant_id, idempotency_key) do nothing
|
||||
returning id, event_type as "eventType", points, balance_after as "balanceAfter",
|
||||
source_type as "sourceType", source_id as "sourceId", created_at as "createdAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
current.rows[0].userId,
|
||||
rewardPoints,
|
||||
balanceAfter,
|
||||
reportId,
|
||||
`feedback_reward:${reportId}`,
|
||||
JSON.stringify({ reportId, status: nextStatus }),
|
||||
],
|
||||
);
|
||||
if (ledger.rows[0]) {
|
||||
const updatedUser = await client.query<{ score: number }>(
|
||||
`
|
||||
update public.platform_users
|
||||
set score = score + $2,
|
||||
updated_at = now()
|
||||
where id = $1
|
||||
returning score
|
||||
`,
|
||||
[current.rows[0].userId, rewardPoints],
|
||||
);
|
||||
reward = { ...ledger.rows[0], balanceAfter: updatedUser.rows[0]?.score || ledger.rows[0].balanceAfter };
|
||||
}
|
||||
}
|
||||
|
||||
await recordAudit(client, auth, 'tenant.feedback.status_updated', 'reports', reportId, {
|
||||
fromStatus: current.rows[0].status,
|
||||
toStatus: nextStatus,
|
||||
rewardPoints,
|
||||
});
|
||||
return { ...result.rows[0], reward };
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
Reference in New Issue
Block a user