feat(frontend): Phase 2/3 hardening release
Roadmap P0
- Ship /logo.svg fallback and rewire asset references (superseded later by
the project-manager PNG in a separate commit).
Roadmap P1
- Response envelope alignment ({items,total,page,size}) across services
and query hooks.
- Approval/ticket UX updates tied to the new backend rollback semantics.
Roadmap P2
- React.lazy + Vite manualChunks to split vendor-radix/charts/icons/forms
from the main bundle and cut initial JS.
- Fix the 181 tsc errors (PaginationParams class + per-service generics).
- Auto-refresh flow for JWT refresh tokens wired into api-client.ts.
Roadmap P3
- i18n: i18next + language detector, en/ar locales, RTL auto-switch,
LanguageToggle component in RoleLayout and AdminLmsLayout.
- GDPR: PrivacyCenter page for data export and right-to-erasure, backed
by gdpr.service.ts; logout via AuthContext after erasure.
- Human-in-the-loop exam review UI: admin ExamReviewQueue + ExamReviewDetail
pages, useExamReview hooks, exam-review.service.ts.
- AI quality loop: AIPromptEditor (versioning + render preview),
AIFeedbackButtons for students, AIFeedbackTriage admin page, dedicated
services, hooks, and types.
- Dark mode: ThemeProvider + ThemeToggle + chart color tokens.
- Accessibility: DialogDescription on every DialogContent; alert-dialog
and sheet updates.
- Retire dead pages: ExamPage marketing, Index, ProfilePage import.
- Playwright e2e scaffolding: playwright.config.ts + e2e/login.spec.ts
smoke tests, npm scripts test:e2e / test:e2e:install.
Made-with: Cursor
This commit is contained in:
@@ -1,4 +1,12 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type {
|
||||
AICourseConfig,
|
||||
AICourseTrack,
|
||||
QualityGateResult,
|
||||
IELTSValidationResult,
|
||||
} from "@/types/ai-course";
|
||||
|
||||
export type { QualityGateResult, IELTSValidationResult };
|
||||
|
||||
export interface AiCourseCreateEnglishRequest {
|
||||
cefr_level: string;
|
||||
@@ -18,7 +26,11 @@ export interface AiCourseCreateResponse {
|
||||
skill?: string;
|
||||
}
|
||||
|
||||
export interface QualityGateResult {
|
||||
/**
|
||||
* Legacy flat shape returned by older backends. The canonical type is the
|
||||
* structured {@link QualityGateResult} re-exported above.
|
||||
*/
|
||||
export interface QualityGateResultLegacy {
|
||||
status: string;
|
||||
readability_score: number;
|
||||
cefr_alignment: boolean;
|
||||
@@ -26,7 +38,7 @@ export interface QualityGateResult {
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
export interface IELTSValidationResult {
|
||||
export interface IELTSValidationResultLegacy {
|
||||
type: string;
|
||||
validation_results: Record<string, unknown>;
|
||||
overall_passed: boolean;
|
||||
@@ -40,10 +52,10 @@ export const aiCourseService = {
|
||||
api.post<AiCourseCreateResponse>("/ai-course/ielts/create", data),
|
||||
|
||||
getCourse: (courseId: number) =>
|
||||
api.get<Record<string, unknown>>(`/ai-course/${courseId}`),
|
||||
api.get<AICourseConfig>(`/ai-course/${courseId}`),
|
||||
|
||||
getTracks: (courseId: number) =>
|
||||
api.get<unknown[]>(`/ai-course/${courseId}/tracks`),
|
||||
api.get<AICourseTrack[]>(`/ai-course/${courseId}/tracks`),
|
||||
|
||||
getQualityGate: (courseId: number) =>
|
||||
api.get<QualityGateResult>(`/ai-course/${courseId}/quality`),
|
||||
|
||||
54
frontend/src/services/ai-feedback.service.ts
Normal file
54
frontend/src/services/ai-feedback.service.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { PaginatedResponse } from "@/types/common";
|
||||
import type {
|
||||
AIFeedback,
|
||||
AIFeedbackStatus,
|
||||
AIFeedbackSubjectType,
|
||||
AIFeedbackSubmitInput,
|
||||
AIFeedbackSummary,
|
||||
} from "@/types/ai-feedback";
|
||||
|
||||
export const aiFeedbackService = {
|
||||
async submit(input: AIFeedbackSubmitInput): Promise<AIFeedback> {
|
||||
return api.post<AIFeedback>("/ai/feedback", input);
|
||||
},
|
||||
|
||||
async summary(
|
||||
subjectType: AIFeedbackSubjectType,
|
||||
subjectId: number,
|
||||
): Promise<AIFeedbackSummary> {
|
||||
return api.get<AIFeedbackSummary>("/ai/feedback/summary", {
|
||||
subject_type: subjectType,
|
||||
subject_id: subjectId,
|
||||
});
|
||||
},
|
||||
|
||||
async list(
|
||||
params: {
|
||||
status?: AIFeedbackStatus;
|
||||
rating?: "up" | "down";
|
||||
subject_type?: AIFeedbackSubjectType;
|
||||
prompt_key?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
} = {},
|
||||
): Promise<PaginatedResponse<AIFeedback>> {
|
||||
const res = await api.get<PaginatedResponse<AIFeedback>>("/ai/feedback", params);
|
||||
const items = res.items ?? res.data ?? [];
|
||||
return {
|
||||
items,
|
||||
total: res.total ?? items.length,
|
||||
page: res.page ?? 1,
|
||||
size: res.size ?? items.length,
|
||||
pages: res.pages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
async resolve(
|
||||
id: number,
|
||||
status: Exclude<AIFeedbackStatus, "open">,
|
||||
notes?: string,
|
||||
): Promise<AIFeedback> {
|
||||
return api.post<AIFeedback>(`/ai/feedback/${id}/resolve`, { status, notes });
|
||||
},
|
||||
};
|
||||
61
frontend/src/services/ai-prompt.service.ts
Normal file
61
frontend/src/services/ai-prompt.service.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { PaginatedResponse } from "@/types/common";
|
||||
import type {
|
||||
AIPrompt,
|
||||
AIPromptCreateInput,
|
||||
AIPromptRenderResponse,
|
||||
AIPromptSummary,
|
||||
} from "@/types/ai-prompt";
|
||||
|
||||
interface PromptListParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export const aiPromptService = {
|
||||
async listKeys(
|
||||
params: PromptListParams = {},
|
||||
): Promise<PaginatedResponse<AIPromptSummary>> {
|
||||
const res = await api.get<PaginatedResponse<AIPromptSummary>>(
|
||||
"/ai/prompts",
|
||||
params,
|
||||
);
|
||||
const items = res.items ?? res.data ?? [];
|
||||
return {
|
||||
items,
|
||||
total: res.total ?? items.length,
|
||||
page: res.page ?? 1,
|
||||
size: res.size ?? items.length,
|
||||
pages: res.pages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
async listVersions(key: string): Promise<AIPromptSummary[]> {
|
||||
const res = await api.get<PaginatedResponse<AIPromptSummary>>(
|
||||
`/ai/prompts/${encodeURIComponent(key)}/versions`,
|
||||
);
|
||||
return res.items ?? res.data ?? [];
|
||||
},
|
||||
|
||||
async get(id: number): Promise<AIPrompt> {
|
||||
return api.get<AIPrompt>(`/ai/prompts/${id}`);
|
||||
},
|
||||
|
||||
async create(input: AIPromptCreateInput): Promise<AIPrompt> {
|
||||
return api.post<AIPrompt>("/ai/prompts", input);
|
||||
},
|
||||
|
||||
async activate(id: number): Promise<AIPrompt> {
|
||||
return api.post<AIPrompt>(`/ai/prompts/${id}/activate`, {});
|
||||
},
|
||||
|
||||
async render(
|
||||
id: number,
|
||||
variables: Record<string, string>,
|
||||
): Promise<AIPromptRenderResponse> {
|
||||
return api.post<AIPromptRenderResponse>(`/ai/prompts/${id}/render`, {
|
||||
variables,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -1,17 +1,36 @@
|
||||
import { api, setToken, clearToken } from "@/lib/api-client";
|
||||
import { api, setToken, clearToken, persistTokenBundle } from "@/lib/api-client";
|
||||
import type { LoginRequest, LoginResponse, User, ResetPasswordRequest, CurrentUserResponse } from "@/types";
|
||||
import type { ApiSuccessResponse } from "@/types";
|
||||
|
||||
export const authService = {
|
||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||
// Odoo accepts `login` or `email` in JSON body
|
||||
const res = await api.post<LoginResponse>("/login", { ...data, email: data.login });
|
||||
setToken(res.token);
|
||||
const res = await api.post<LoginResponse & {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
}>("/login", { ...data, email: data.login });
|
||||
// Persist the full bundle so the transparent refresh loop in api-client
|
||||
// has everything it needs. Falls back to the legacy single-token field
|
||||
// for older backends that only return ``token``.
|
||||
if (res.access_token || res.refresh_token) {
|
||||
persistTokenBundle({
|
||||
access_token: res.access_token,
|
||||
token: res.token,
|
||||
refresh_token: res.refresh_token,
|
||||
expires_in: res.expires_in,
|
||||
});
|
||||
} else {
|
||||
setToken(res.token);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
await api.post<void>("/logout").catch(() => {});
|
||||
const refresh = localStorage.getItem("encoach_refresh_token") || undefined;
|
||||
await api
|
||||
.post<void>("/logout", refresh ? { refresh_token: refresh } : undefined)
|
||||
.catch(() => {});
|
||||
clearToken();
|
||||
},
|
||||
|
||||
|
||||
57
frontend/src/services/exam-review.service.ts
Normal file
57
frontend/src/services/exam-review.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { PaginatedResponse } from "@/types/common";
|
||||
import type {
|
||||
ExamReviewDetail,
|
||||
ExamReviewListItem,
|
||||
} from "@/types/exam-review";
|
||||
|
||||
export interface ExamReviewQueueParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
search?: string;
|
||||
status?: "draft" | "pending_review" | "published" | "archived";
|
||||
subject_id?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-facing review queue. Everything here assumes the caller has an admin
|
||||
* JWT — the server is authoritative; the frontend just surfaces errors.
|
||||
*/
|
||||
export const examReviewService = {
|
||||
async queue(
|
||||
params: ExamReviewQueueParams = {},
|
||||
): Promise<PaginatedResponse<ExamReviewListItem>> {
|
||||
const res = await api.get<PaginatedResponse<ExamReviewListItem>>(
|
||||
"/exam/review/queue",
|
||||
params,
|
||||
);
|
||||
const items = res.items ?? res.data ?? [];
|
||||
return {
|
||||
items,
|
||||
total: res.total ?? items.length,
|
||||
page: res.page ?? 1,
|
||||
size: res.size ?? items.length,
|
||||
pages: res.pages ?? 1,
|
||||
};
|
||||
},
|
||||
|
||||
async detail(examId: number): Promise<ExamReviewDetail> {
|
||||
return api.get<ExamReviewDetail>(`/exam/review/${examId}`);
|
||||
},
|
||||
|
||||
async approve(
|
||||
examId: number,
|
||||
notes?: string,
|
||||
): Promise<ExamReviewListItem> {
|
||||
return api.post<ExamReviewListItem>(
|
||||
`/exam/review/${examId}/approve`,
|
||||
notes ? { notes } : {},
|
||||
);
|
||||
},
|
||||
|
||||
async reject(examId: number, notes: string): Promise<ExamReviewListItem> {
|
||||
return api.post<ExamReviewListItem>(`/exam/review/${examId}/reject`, {
|
||||
notes,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -15,6 +15,6 @@ export const examSessionService = {
|
||||
getStatus: (examId: number) =>
|
||||
api.get<{ status: string; scores_available: boolean }>(`/exam/${examId}/status`),
|
||||
|
||||
getResults: (examId: number) =>
|
||||
api.get(`/exam/${examId}/results`),
|
||||
getResults: <T = unknown>(examId: number) =>
|
||||
api.get<T>(`/exam/${examId}/results`),
|
||||
};
|
||||
|
||||
36
frontend/src/services/gdpr.service.ts
Normal file
36
frontend/src/services/gdpr.service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
export interface GdprExportPayload {
|
||||
profile: Record<string, unknown>;
|
||||
partner: Record<string, unknown>;
|
||||
entity_memberships: unknown[];
|
||||
exam_attempts: unknown[];
|
||||
exam_answers: unknown[];
|
||||
ai_feedback: unknown[];
|
||||
ai_calls: unknown[];
|
||||
tickets: unknown[];
|
||||
coaching_sessions: unknown[];
|
||||
exported_at: string;
|
||||
export_format_version: string;
|
||||
}
|
||||
|
||||
export interface GdprEraseResponse {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
summary: {
|
||||
anonymised_partner_fields: string[];
|
||||
deactivated_user: boolean;
|
||||
deleted_feedback_count: number;
|
||||
deleted_coaching_count: number;
|
||||
retained_attempts_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const gdprService = {
|
||||
async exportMyData(): Promise<GdprExportPayload> {
|
||||
return api.get<GdprExportPayload>("/gdpr/export");
|
||||
},
|
||||
async eraseMyAccount(): Promise<GdprEraseResponse> {
|
||||
return api.post<GdprEraseResponse>("/gdpr/delete", { confirm: true });
|
||||
},
|
||||
};
|
||||
@@ -130,9 +130,10 @@ export const lmsService = {
|
||||
},
|
||||
|
||||
async listCourses(params?: PaginationParams & { status?: string }): Promise<PaginatedResponse<Course>> {
|
||||
const raw = await api.get<unknown>("/courses", params as Record<string, string | number | boolean | undefined>);
|
||||
const raw = await api.get<unknown>("/courses", params);
|
||||
const p = asPaginated<Record<string, unknown>>(raw);
|
||||
return { ...p, items: p.items.map(mapCourseRaw) };
|
||||
const items = p.items.map(mapCourseRaw);
|
||||
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
|
||||
},
|
||||
|
||||
async getCourse(id: number): Promise<Course> {
|
||||
@@ -183,9 +184,10 @@ export const lmsService = {
|
||||
},
|
||||
|
||||
async listBatches(params?: PaginationParams): Promise<PaginatedResponse<Batch>> {
|
||||
const raw = await api.get<unknown>("/batches", params as Record<string, string | number | boolean | undefined>);
|
||||
const raw = await api.get<unknown>("/batches", params);
|
||||
const p = asPaginated<Record<string, unknown>>(raw);
|
||||
return { ...p, items: p.items.map(mapBatchRaw) };
|
||||
const items = p.items.map(mapBatchRaw);
|
||||
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
|
||||
},
|
||||
|
||||
async getBatch(id: number): Promise<Batch> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
import type { ExamSession, ExamStat, StatisticalData } from "@/types";
|
||||
import type { StatsExamSession as ExamSession, ExamStat, StatisticalData } from "@/types";
|
||||
|
||||
export const statsService = {
|
||||
async getSessions(params?: Record<string, string | number | boolean | undefined>): Promise<ExamSession[]> {
|
||||
|
||||
@@ -11,7 +11,8 @@ export const taxonomyService = {
|
||||
|
||||
async getSubject(id: number): Promise<Subject> {
|
||||
const res = await api.get<{ data: Subject } | Subject>(`/subjects/${id}`);
|
||||
return (res as { data: Subject }).data ?? res;
|
||||
const wrapped = res as { data?: Subject };
|
||||
return (wrapped.data ?? (res as Subject));
|
||||
},
|
||||
|
||||
async createSubject(data: Partial<Subject>): Promise<Subject> {
|
||||
@@ -28,7 +29,8 @@ export const taxonomyService = {
|
||||
|
||||
async getTaxonomyTree(subjectId: number): Promise<TaxonomyTree> {
|
||||
const res = await api.get<{ data: TaxonomyTree } | TaxonomyTree>(`/subjects/${subjectId}/taxonomy`);
|
||||
return (res as { data: TaxonomyTree }).data ?? res;
|
||||
const wrapped = res as { data?: TaxonomyTree };
|
||||
return (wrapped.data ?? (res as TaxonomyTree));
|
||||
},
|
||||
|
||||
async importTaxonomy(subjectId: number, data: FormData): Promise<ApiSuccessResponse> {
|
||||
|
||||
@@ -25,9 +25,10 @@ function mapPortalUser(raw: Record<string, unknown>): User {
|
||||
|
||||
export const usersService = {
|
||||
async list(params: UserListParams): Promise<PaginatedResponse<User>> {
|
||||
const raw = await api.get<unknown>("/users/list", params as Record<string, string | number | boolean | undefined>);
|
||||
const raw = await api.get<unknown>("/users/list", params);
|
||||
const p = asPaginated<Record<string, unknown>>(raw);
|
||||
return { ...p, items: p.items.map(mapPortalUser) };
|
||||
const items = p.items.map(mapPortalUser);
|
||||
return { items, total: p.total, page: p.page, size: p.size, pages: p.pages };
|
||||
},
|
||||
|
||||
async getById(id: number): Promise<User> {
|
||||
|
||||
Reference in New Issue
Block a user