Ship admin auth, user overview, assessments, chats, assignment filters/export, security hardening, and a separate desktop admin shell with clearer assessment answers. Co-authored-by: Cursor <cursoragent@cursor.com>
490 lines
13 KiB
TypeScript
490 lines
13 KiB
TypeScript
import { api, BackendError, clearSessionToken, getSessionToken } from '@/services/backend';
|
|
|
|
export type AdminStats = {
|
|
totalUsers: number;
|
|
guestUsers: number;
|
|
registeredUsers: number;
|
|
pretestCompletedUsers: number;
|
|
posttestCompletedUsers: number;
|
|
trainingInProgressUsers: number;
|
|
trainingCompletedUsers: number;
|
|
courseCompletedUsers: number;
|
|
totalAssessmentAttempts: number;
|
|
totalChatSessions: number;
|
|
totalChatMessages: number;
|
|
programSessionCount: number;
|
|
};
|
|
|
|
export type AdminUserListItem = {
|
|
id: string;
|
|
email: string | null;
|
|
role: 'user' | 'admin';
|
|
isGuest: boolean;
|
|
profileComplete: boolean;
|
|
name: string | null;
|
|
age: number | null;
|
|
createdAt: string;
|
|
lastActivityAt: string | null;
|
|
currentStep: string | null;
|
|
pretestCompleted: boolean;
|
|
posttestCompleted: boolean;
|
|
trainingCompleted: boolean;
|
|
courseCompleted: boolean;
|
|
completedSessionCount: number;
|
|
programSessionCount: number;
|
|
};
|
|
|
|
export type AdminUserListResult = {
|
|
items: AdminUserListItem[];
|
|
pagination: {
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
totalPages: number;
|
|
};
|
|
};
|
|
|
|
export type AdminUsersQuery = {
|
|
q?: string;
|
|
page?: number;
|
|
pageSize?: number;
|
|
sortBy?: 'createdAt' | 'role' | 'externalId' | 'lastActivityAt';
|
|
sortDir?: 'asc' | 'desc';
|
|
};
|
|
|
|
function toQueryString(params: AdminUsersQuery): string {
|
|
const search = new URLSearchParams();
|
|
if (params.q) search.set('q', params.q);
|
|
if (params.page) search.set('page', String(params.page));
|
|
if (params.pageSize) search.set('pageSize', String(params.pageSize));
|
|
if (params.sortBy) search.set('sortBy', params.sortBy);
|
|
if (params.sortDir) search.set('sortDir', params.sortDir);
|
|
const qs = search.toString();
|
|
return qs ? `?${qs}` : '';
|
|
}
|
|
|
|
export function fetchAdminStats(): Promise<AdminStats> {
|
|
return api.get<AdminStats>('/admin/stats');
|
|
}
|
|
|
|
export function fetchAdminUsers(query: AdminUsersQuery = {}): Promise<AdminUserListResult> {
|
|
return api.get<AdminUserListResult>(`/admin/users${toQueryString(query)}`);
|
|
}
|
|
|
|
export type AdminAssignmentSummary = {
|
|
total: number;
|
|
notStarted: number;
|
|
opened: number;
|
|
inProgress: number;
|
|
completed: number;
|
|
};
|
|
|
|
export type AdminUserDetail = {
|
|
id: string;
|
|
email: string | null;
|
|
role: 'user' | 'admin';
|
|
isGuest: boolean;
|
|
profileComplete: boolean;
|
|
name: string | null;
|
|
age: number | null;
|
|
createdAt: string;
|
|
lastActivityAt: string | null;
|
|
currentStep: string | null;
|
|
permissions: {
|
|
canTakePreTest: boolean;
|
|
canOpenTraining: boolean;
|
|
canTakePostTest: boolean;
|
|
isCompleted: boolean;
|
|
} | null;
|
|
pretestCompleted: boolean;
|
|
posttestCompleted: boolean;
|
|
trainingStarted: boolean;
|
|
trainingCompleted: boolean;
|
|
courseCompleted: boolean;
|
|
completedSessionCount: number;
|
|
programSessionCount: number;
|
|
preTestCompletedAt: string | null;
|
|
trainingStartedAt: string | null;
|
|
trainingCompletedAt: string | null;
|
|
postTestCompletedAt: string | null;
|
|
hasPretestAttempt: boolean;
|
|
hasPosttestAttempt: boolean;
|
|
chatSessionCount: number;
|
|
assignments: AdminAssignmentSummary;
|
|
};
|
|
|
|
export function fetchAdminUserDetail(externalId: string): Promise<AdminUserDetail> {
|
|
return api.get<AdminUserDetail>(`/admin/users/${encodeURIComponent(externalId)}`);
|
|
}
|
|
|
|
export type AdminUserOverview = {
|
|
user: { id: string; email: string | null };
|
|
assignments: {
|
|
total: number;
|
|
completed: number;
|
|
inProgress: number;
|
|
notStarted: number;
|
|
reported: number;
|
|
opened: number;
|
|
progressPercent: number | null;
|
|
lastActivityAt: string | null;
|
|
};
|
|
learningProgress: {
|
|
currentStep: string | null;
|
|
trainingStarted: boolean;
|
|
trainingCompleted: boolean;
|
|
courseCompleted: boolean;
|
|
totalLessons: number;
|
|
completedLessons: number;
|
|
progressPercent: number | null;
|
|
lastActivityAt: string | null;
|
|
};
|
|
assessments: {
|
|
completedCount: number;
|
|
expectedCount: number;
|
|
remainingCount: number;
|
|
hasPretest: boolean;
|
|
hasPosttest: boolean;
|
|
pretestCompletedAt: string | null;
|
|
posttestCompletedAt: string | null;
|
|
lastAssessmentAt: string | null;
|
|
overallStatus: 'none' | 'partial' | 'complete';
|
|
};
|
|
recentActivities: Array<{
|
|
type:
|
|
| 'session_completed'
|
|
| 'assignment_activated'
|
|
| 'assignment_completed'
|
|
| 'assessment_submitted'
|
|
| 'chat_started';
|
|
title: string;
|
|
occurredAt: string;
|
|
relatedId: string | null;
|
|
}>;
|
|
};
|
|
|
|
export function fetchAdminUserOverview(externalId: string): Promise<AdminUserOverview> {
|
|
return api.get<AdminUserOverview>(
|
|
`/admin/users/${encodeURIComponent(externalId)}/overview`,
|
|
);
|
|
}
|
|
|
|
export type AdminAssessmentScores = {
|
|
total: number | null;
|
|
efficacy: number | null;
|
|
hope: number | null;
|
|
resilience: number | null;
|
|
optimism: number | null;
|
|
};
|
|
|
|
export type AdminAssessmentAnswer = {
|
|
questionId: string;
|
|
questionNumber: number;
|
|
rawValue: number;
|
|
points: number;
|
|
dimension: string;
|
|
isReverseScored: boolean;
|
|
};
|
|
|
|
export type AdminAssessmentPhaseView = {
|
|
exists: boolean;
|
|
phase: 'pre' | 'post';
|
|
attemptId: string | null;
|
|
status: string | null;
|
|
submittedAt: string | null;
|
|
scoredAt: string | null;
|
|
scores: AdminAssessmentScores | null;
|
|
answerCount: number;
|
|
expectedAnswerCount: number;
|
|
answers: AdminAssessmentAnswer[];
|
|
};
|
|
|
|
export type AdminUserAssessments = {
|
|
user: { id: string; email: string | null };
|
|
pretest: AdminAssessmentPhaseView;
|
|
posttest: AdminAssessmentPhaseView;
|
|
};
|
|
|
|
export function fetchAdminUserAssessments(externalId: string): Promise<AdminUserAssessments> {
|
|
return api.get<AdminUserAssessments>(
|
|
`/admin/users/${encodeURIComponent(externalId)}/assessments`,
|
|
);
|
|
}
|
|
|
|
export type AdminChatSessionListItem = {
|
|
id: string;
|
|
sessionOrder: number;
|
|
title: string;
|
|
status: 'open' | 'ended';
|
|
startedAt: string;
|
|
endedAt: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
lastActivityAt: string;
|
|
messageCount: number;
|
|
};
|
|
|
|
export type AdminChatSessionsListResult = {
|
|
user: { id: string; email: string | null };
|
|
items: AdminChatSessionListItem[];
|
|
pagination: {
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
totalPages: number;
|
|
};
|
|
};
|
|
|
|
export type AdminChatMessageView = {
|
|
id: string;
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
createdAt: string;
|
|
sequence: number;
|
|
};
|
|
|
|
export type AdminChatSessionDetail = {
|
|
user: { id: string; email: string | null };
|
|
session: AdminChatSessionListItem;
|
|
messages: AdminChatMessageView[];
|
|
};
|
|
|
|
export type AdminChatSessionsQuery = {
|
|
page?: number;
|
|
pageSize?: number;
|
|
sortBy?: 'startedAt' | 'updatedAt' | 'sessionOrder' | 'createdAt';
|
|
sortDir?: 'asc' | 'desc';
|
|
};
|
|
|
|
function chatSessionsQueryString(params: AdminChatSessionsQuery): string {
|
|
const search = new URLSearchParams();
|
|
if (params.page) search.set('page', String(params.page));
|
|
if (params.pageSize) search.set('pageSize', String(params.pageSize));
|
|
if (params.sortBy) search.set('sortBy', params.sortBy);
|
|
if (params.sortDir) search.set('sortDir', params.sortDir);
|
|
const qs = search.toString();
|
|
return qs ? `?${qs}` : '';
|
|
}
|
|
|
|
export function fetchAdminUserChatSessions(
|
|
externalId: string,
|
|
query: AdminChatSessionsQuery = {},
|
|
): Promise<AdminChatSessionsListResult> {
|
|
return api.get<AdminChatSessionsListResult>(
|
|
`/admin/users/${encodeURIComponent(externalId)}/chat-sessions${chatSessionsQueryString(query)}`,
|
|
);
|
|
}
|
|
|
|
export function fetchAdminUserChatSessionDetail(
|
|
externalId: string,
|
|
sessionId: string,
|
|
): Promise<AdminChatSessionDetail> {
|
|
return api.get<AdminChatSessionDetail>(
|
|
`/admin/users/${encodeURIComponent(externalId)}/chat-sessions/${encodeURIComponent(sessionId)}`,
|
|
);
|
|
}
|
|
|
|
export type AdminAssignmentListItem = {
|
|
assignmentId: string;
|
|
definitionId: string;
|
|
definitionKey: string;
|
|
definitionTitle: string;
|
|
assignmentType: string;
|
|
programId: null;
|
|
programTitle: null;
|
|
sessionId: string | null;
|
|
sessionOrder: number;
|
|
sessionTitle: string | null;
|
|
status: string;
|
|
assignedAt: string;
|
|
startedAt: string | null;
|
|
completedAt: string | null;
|
|
lastActivityAt: string | null;
|
|
doneDayCount: number;
|
|
expectedDurationDays: number | null;
|
|
progressPercent: number | null;
|
|
};
|
|
|
|
export type AdminAssignmentsSummary = {
|
|
total: number;
|
|
notStarted: number;
|
|
opened: number;
|
|
inProgress: number;
|
|
completed: number;
|
|
pending: number;
|
|
progressPercent: number | null;
|
|
};
|
|
|
|
export type AdminLearningProgress = {
|
|
currentStep: string | null;
|
|
trainingStarted: boolean;
|
|
trainingCompleted: boolean;
|
|
courseCompleted: boolean;
|
|
totalLessons: number;
|
|
completedLessons: number;
|
|
progressPercent: number | null;
|
|
};
|
|
|
|
export type AdminUserAssignmentsResult = {
|
|
user: { id: string; email: string | null };
|
|
learningProgress: AdminLearningProgress;
|
|
summary: AdminAssignmentsSummary;
|
|
items: AdminAssignmentListItem[];
|
|
};
|
|
|
|
export type AdminAssignmentsQuery = {
|
|
sortBy?: 'activatedAt' | 'completedAt' | 'status' | 'sessionOrder';
|
|
sortDir?: 'asc' | 'desc';
|
|
status?: 'NOT_STARTED' | 'ACTIVE' | 'IN_PROGRESS' | 'REPORTED' | 'COMPLETED';
|
|
assignmentType?: 'ONE_TIME' | 'DAILY' | 'ONGOING';
|
|
search?: string;
|
|
};
|
|
|
|
function assignmentsQueryString(params: AdminAssignmentsQuery): string {
|
|
const search = new URLSearchParams();
|
|
if (params.sortBy) search.set('sortBy', params.sortBy);
|
|
if (params.sortDir) search.set('sortDir', params.sortDir);
|
|
if (params.status) search.set('status', params.status);
|
|
if (params.assignmentType) search.set('assignmentType', params.assignmentType);
|
|
if (params.search) search.set('search', params.search);
|
|
const qs = search.toString();
|
|
return qs ? `?${qs}` : '';
|
|
}
|
|
|
|
export function fetchAdminUserAssignments(
|
|
externalId: string,
|
|
query: AdminAssignmentsQuery = {},
|
|
): Promise<AdminUserAssignmentsResult> {
|
|
return api.get<AdminUserAssignmentsResult>(
|
|
`/admin/users/${encodeURIComponent(externalId)}/assignments${assignmentsQueryString(query)}`,
|
|
);
|
|
}
|
|
|
|
export type AdminAssignmentDetail = {
|
|
user: { id: string; email: string | null };
|
|
assignment: {
|
|
id: string;
|
|
definitionId: string;
|
|
definitionKey: string;
|
|
definitionTitle: string;
|
|
assignmentType: string;
|
|
status: string;
|
|
assignedAt: string;
|
|
startedAt: string | null;
|
|
completedAt: string | null;
|
|
lastActivityAt: string | null;
|
|
doneDayCount: number;
|
|
expectedDurationDays: number | null;
|
|
progressPercent: number | null;
|
|
};
|
|
session: {
|
|
id: string | null;
|
|
order: number;
|
|
title: string;
|
|
};
|
|
learningProgress: {
|
|
currentStep: string | null;
|
|
totalLessons: number;
|
|
completedLessons: number;
|
|
progressPercent: number | null;
|
|
};
|
|
activity: {
|
|
firstActivityAt: string | null;
|
|
lastActivityAt: string | null;
|
|
completedActivityCount: number;
|
|
} | null;
|
|
};
|
|
|
|
export function fetchAdminUserAssignmentDetail(
|
|
externalId: string,
|
|
assignmentId: string,
|
|
): Promise<AdminAssignmentDetail> {
|
|
return api.get<AdminAssignmentDetail>(
|
|
`/admin/users/${encodeURIComponent(externalId)}/assignments/${encodeURIComponent(assignmentId)}`,
|
|
);
|
|
}
|
|
|
|
function parseContentDispositionFilename(header: string | null): string | null {
|
|
if (!header) return null;
|
|
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
|
if (utf8?.[1]) {
|
|
try {
|
|
return decodeURIComponent(utf8[1].trim().replace(/^"|"$/g, ''));
|
|
} catch {
|
|
// fall through
|
|
}
|
|
}
|
|
const plain = /filename="?([^";]+)"?/i.exec(header);
|
|
if (!plain?.[1]) return null;
|
|
const name = plain[1].trim();
|
|
if (!name || /[\\/\n\r"]/.test(name)) return null;
|
|
return name;
|
|
}
|
|
|
|
function triggerBrowserDownload(blob: Blob, filename: string): void {
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
anchor.rel = 'noopener';
|
|
document.body.appendChild(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
/**
|
|
* Binary XLSX download — bypasses JSON api.get helper.
|
|
*/
|
|
export async function downloadAdminUserAssignmentsExport(
|
|
externalId: string,
|
|
query: AdminAssignmentsQuery = {},
|
|
): Promise<void> {
|
|
const path = `/admin/users/${encodeURIComponent(externalId)}/assignments/export${assignmentsQueryString(query)}`;
|
|
const baseUrl = (import.meta.env.VITE_BACKEND_URL || '/api/backend').replace(/\/$/, '');
|
|
const token = getSessionToken();
|
|
const headers = new Headers();
|
|
if (token) {
|
|
headers.set('Authorization', `Bearer ${token}`);
|
|
}
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetch(`${baseUrl}${path}`, { method: 'GET', headers });
|
|
} catch {
|
|
throw new BackendError(
|
|
0,
|
|
'NETWORK_ERROR',
|
|
'سرور در دسترس نیست. لطفاً چند لحظه بعد دوباره تلاش کنید.',
|
|
);
|
|
}
|
|
|
|
if (!response.ok) {
|
|
let code = 'UNKNOWN';
|
|
let message = `Request failed with status ${response.status}`;
|
|
try {
|
|
const body = (await response.json()) as {
|
|
error?: { code?: string; message?: string };
|
|
};
|
|
code = body.error?.code ?? code;
|
|
message = body.error?.message ?? message;
|
|
} catch {
|
|
// non-JSON error body
|
|
}
|
|
if (response.status === 401) {
|
|
clearSessionToken();
|
|
if (typeof window !== 'undefined') {
|
|
window.dispatchEvent(new CustomEvent('novya:unauthorized'));
|
|
}
|
|
}
|
|
throw new BackendError(response.status, code, message);
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const fromHeader = parseContentDispositionFilename(
|
|
response.headers.get('Content-Disposition'),
|
|
);
|
|
const safeFallback = `noya-assignments-${externalId.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 64) || 'user'}.xlsx`;
|
|
triggerBrowserDownload(blob, fromHeader ?? safeFallback);
|
|
}
|