Files
pwa/src/services/adminService.ts
T
ar.badieiandCursor c6243caa35 Add read-only Noya admin panel with assignments and UX polish.
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>
2026-09-21 08:00:24 +03:30

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);
}