433 lines
13 KiB
TypeScript
433 lines
13 KiB
TypeScript
// В браузере — относительный URL (запросы на тот же хост, Next проксирует /api на бэкенд)
|
||
const API_BASE =
|
||
typeof window !== 'undefined' ? '' : (process.env.INTERNAL_API_URL || process.env.API_URL || 'http://localhost:3125');
|
||
const API_URL = API_BASE ? `${API_BASE.replace(/\/$/, '')}/api` : '/api';
|
||
|
||
const STORAGE_KEY = 'coursecraft_api_token';
|
||
|
||
/** JWT from backend (set after exchangeToken). API expects JWT in Authorization header. */
|
||
let apiToken: string | null = null;
|
||
|
||
function getStoredToken(): string | null {
|
||
if (typeof window === 'undefined') return null;
|
||
try {
|
||
return sessionStorage.getItem(STORAGE_KEY);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function setApiToken(token: string | null) {
|
||
apiToken = token;
|
||
try {
|
||
if (typeof window !== 'undefined') {
|
||
if (token) sessionStorage.setItem(STORAGE_KEY, token);
|
||
else sessionStorage.removeItem(STORAGE_KEY);
|
||
}
|
||
} catch {
|
||
// ignore storage errors (e.g. private mode)
|
||
}
|
||
}
|
||
|
||
/** Returns current JWT (memory or sessionStorage after reload). */
|
||
function getApiToken(): string | null {
|
||
if (apiToken) return apiToken;
|
||
const stored = getStoredToken();
|
||
if (stored) {
|
||
apiToken = stored;
|
||
return stored;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
class ApiClient {
|
||
private getAuthHeaders(): HeadersInit {
|
||
const headers: HeadersInit = {
|
||
'Content-Type': 'application/json',
|
||
};
|
||
const token = getApiToken();
|
||
if (token) {
|
||
headers['Authorization'] = `Bearer ${token}`;
|
||
}
|
||
return headers;
|
||
}
|
||
|
||
private async request<T>(
|
||
endpoint: string,
|
||
options: RequestInit = {}
|
||
): Promise<T> {
|
||
const headers = this.getAuthHeaders();
|
||
|
||
const response = await fetch(`${API_URL}${endpoint}`, {
|
||
...options,
|
||
headers: {
|
||
...headers,
|
||
...options.headers,
|
||
},
|
||
});
|
||
|
||
if (!response.ok) {
|
||
if (response.status === 401) {
|
||
setApiToken(null);
|
||
if (typeof window !== 'undefined') {
|
||
window.dispatchEvent(new CustomEvent('auth:unauthorized'));
|
||
}
|
||
}
|
||
const error = await response.json().catch(() => ({ message: 'Request failed' }));
|
||
throw new Error(error.message || `HTTP ${response.status}`);
|
||
}
|
||
|
||
return response.json();
|
||
}
|
||
|
||
// Auth - Exchange Supabase token for internal JWT (no Authorization header)
|
||
async exchangeToken(supabaseToken: string) {
|
||
return this.request<{ accessToken: string; user: any }>('/auth/exchange', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ supabaseToken }),
|
||
});
|
||
}
|
||
|
||
// User
|
||
async getProfile() {
|
||
return this.request<any>('/users/me');
|
||
}
|
||
|
||
async updateProfile(data: { name?: string; bio?: string }) {
|
||
return this.request<any>('/users/me', {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async getSettings() {
|
||
return this.request<any>('/users/me/settings');
|
||
}
|
||
|
||
async updateSettings(data: { customAiModel?: string; language?: string; theme?: string }) {
|
||
return this.request<any>('/users/me/settings', {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
// Courses
|
||
async getCourses(params?: { status?: string; page?: number; limit?: number }) {
|
||
const searchParams = new URLSearchParams();
|
||
if (params?.status) searchParams.set('status', params.status);
|
||
if (params?.page) searchParams.set('page', String(params.page));
|
||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||
|
||
const query = searchParams.toString();
|
||
return this.request<{ data: any[]; meta: any }>(`/courses${query ? `?${query}` : ''}`);
|
||
}
|
||
|
||
async getCourse(id: string) {
|
||
return this.request<any>(`/courses/${id}`);
|
||
}
|
||
|
||
async createCourse(data: { title: string; description?: string }) {
|
||
return this.request<any>('/courses', {
|
||
method: 'POST',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateCourse(id: string, data: any) {
|
||
return this.request<any>(`/courses/${id}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteCourse(id: string) {
|
||
return this.request<void>(`/courses/${id}`, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
// Chapters
|
||
async createChapter(courseId: string, data: { title: string; description?: string }) {
|
||
return this.request<any>(`/courses/${courseId}/chapters`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async updateChapter(courseId: string, chapterId: string, data: any) {
|
||
return this.request<any>(`/courses/${courseId}/chapters/${chapterId}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async deleteChapter(courseId: string, chapterId: string) {
|
||
return this.request<void>(`/courses/${courseId}/chapters/${chapterId}`, {
|
||
method: 'DELETE',
|
||
});
|
||
}
|
||
|
||
// Lessons (API: GET/PATCH /courses/:courseId/lessons/:lessonId)
|
||
async getLesson(courseId: string, lessonId: string) {
|
||
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}`);
|
||
}
|
||
|
||
async updateLesson(courseId: string, lessonId: string, data: any) {
|
||
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}`, {
|
||
method: 'PATCH',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async getLessonQuiz(courseId: string, lessonId: string) {
|
||
return this.request<any>(`/courses/${courseId}/lessons/${lessonId}/quiz`);
|
||
}
|
||
|
||
// Generation
|
||
async startGeneration(prompt: string) {
|
||
return this.request<{ id: string; status: string; progress: number }>('/generation/start', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ prompt }),
|
||
});
|
||
}
|
||
|
||
async getGenerationStatus(id: string) {
|
||
return this.request<{
|
||
id: string;
|
||
status: string;
|
||
progress: number;
|
||
currentStep: string | null;
|
||
questions: any[] | null;
|
||
generatedOutline: any | null;
|
||
errorMessage: string | null;
|
||
course: { id: string; slug: string } | null;
|
||
}>(`/generation/${id}/status`);
|
||
}
|
||
|
||
async answerQuestions(id: string, answers: Record<string, string | string[]>) {
|
||
return this.request<{ success: boolean }>(`/generation/${id}/answer`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ answers }),
|
||
});
|
||
}
|
||
|
||
async cancelGeneration(id: string) {
|
||
return this.request<{ success: boolean }>(`/generation/${id}/cancel`, {
|
||
method: 'POST',
|
||
});
|
||
}
|
||
|
||
// Subscription
|
||
async getSubscription() {
|
||
return this.request<any>('/payments/subscription');
|
||
}
|
||
|
||
async createCheckoutSession(priceId: string) {
|
||
return this.request<{ url: string }>('/payments/create-checkout-session', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ priceId }),
|
||
});
|
||
}
|
||
|
||
async createPortalSession() {
|
||
return this.request<{ url: string }>('/payments/create-portal-session', {
|
||
method: 'POST',
|
||
});
|
||
}
|
||
|
||
// Catalog (public courses)
|
||
async getCatalog(params?: { page?: number; limit?: number; search?: string; difficulty?: string }) {
|
||
const searchParams = new URLSearchParams();
|
||
if (params?.page) searchParams.set('page', String(params.page));
|
||
if (params?.limit) searchParams.set('limit', String(params.limit));
|
||
if (params?.search) searchParams.set('search', params.search);
|
||
if (params?.difficulty) searchParams.set('difficulty', params.difficulty);
|
||
const query = searchParams.toString();
|
||
return this.request<{ data: any[]; meta: any }>(`/catalog${query ? `?${query}` : ''}`);
|
||
}
|
||
|
||
async getPublicCourse(id: string) {
|
||
return this.request<any>(`/catalog/${id}`);
|
||
}
|
||
|
||
async publishCourse(id: string) {
|
||
return this.request<any>(`/catalog/${id}/submit`, { method: 'POST' });
|
||
}
|
||
|
||
async checkoutCourse(id: string) {
|
||
return this.request<{ url: string }>(`/catalog/${id}/checkout`, { method: 'POST' });
|
||
}
|
||
|
||
async toggleCourseVerification(id: string) {
|
||
return this.request<any>(`/catalog/${id}/verify`, { method: 'PATCH' });
|
||
}
|
||
|
||
// Enrollment & Progress
|
||
async enrollInCourse(courseId: string) {
|
||
return this.request<any>(`/enrollment/${courseId}/enroll`, { method: 'POST' });
|
||
}
|
||
|
||
async getMyEnrollments() {
|
||
return this.request<any[]>('/enrollment');
|
||
}
|
||
|
||
async getEnrollmentProgress(courseId: string) {
|
||
return this.request<any>(`/enrollment/${courseId}/progress`);
|
||
}
|
||
|
||
async completeLesson(courseId: string, lessonId: string) {
|
||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/complete`, { method: 'POST' });
|
||
}
|
||
|
||
async submitQuizAnswers(courseId: string, lessonId: string, answers: number[]) {
|
||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/quiz`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ answers }),
|
||
});
|
||
}
|
||
|
||
async getLessonHomework(courseId: string, lessonId: string) {
|
||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`);
|
||
}
|
||
|
||
async submitLessonHomework(courseId: string, lessonId: string, content: string) {
|
||
return this.request<any>(`/enrollment/${courseId}/lessons/${lessonId}/homework`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ content }),
|
||
});
|
||
}
|
||
|
||
async createReview(courseId: string, data: { rating: number; title?: string; content?: string }) {
|
||
return this.request<any>(`/enrollment/${courseId}/review`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async getCourseReviews(courseId: string, page?: number) {
|
||
const params = page ? `?page=${page}` : '';
|
||
return this.request<{ data: any[]; meta: any }>(`/enrollment/${courseId}/reviews${params}`);
|
||
}
|
||
|
||
// Certificates
|
||
async getCertificate(courseId: string) {
|
||
return this.request<{ certificateUrl: string; html: string }>(`/certificates/${courseId}`);
|
||
}
|
||
|
||
async getCertificateData(courseId: string) {
|
||
return this.request<{ userName: string; courseTitle: string; completedAt: string }>(
|
||
`/certificates/${courseId}/data`
|
||
);
|
||
}
|
||
|
||
async reviewHomeworkSubmission(
|
||
courseId: string,
|
||
submissionId: string,
|
||
data: { teacherScore: number; teacherFeedback?: string }
|
||
) {
|
||
return this.request<any>(`/courses/${courseId}/homework-submissions/${submissionId}/review`, {
|
||
method: 'POST',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async getHomeworkSubmissions(courseId: string) {
|
||
return this.request<any[]>(`/courses/${courseId}/homework-submissions`);
|
||
}
|
||
|
||
// Groups
|
||
async getDefaultCourseGroup(courseId: string) {
|
||
return this.request<any>(`/groups/course/${courseId}/default`);
|
||
}
|
||
|
||
async getGroupMessages(groupId: string) {
|
||
return this.request<any[]>(`/groups/${groupId}/messages`);
|
||
}
|
||
|
||
async getGroupMembers(groupId: string) {
|
||
return this.request<any[]>(`/groups/${groupId}/members`);
|
||
}
|
||
|
||
async sendGroupMessage(groupId: string, content: string) {
|
||
return this.request<any>(`/groups/${groupId}/messages`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ content }),
|
||
});
|
||
}
|
||
|
||
async createGroupInviteLink(groupId: string) {
|
||
return this.request<{ inviteUrl: string }>(`/groups/${groupId}/invite-link`, {
|
||
method: 'POST',
|
||
});
|
||
}
|
||
|
||
async joinGroupByInvite(groupId: string) {
|
||
return this.request<any>(`/groups/join/${groupId}`, { method: 'POST' });
|
||
}
|
||
|
||
// Support
|
||
async createSupportTicket(data: { title: string; initialMessage?: string; priority?: string }) {
|
||
return this.request<any>('/support/tickets', {
|
||
method: 'POST',
|
||
body: JSON.stringify(data),
|
||
});
|
||
}
|
||
|
||
async getMySupportTickets() {
|
||
return this.request<any[]>('/support/tickets');
|
||
}
|
||
|
||
async getSupportTicketMessages(id: string) {
|
||
return this.request<any[]>(`/support/tickets/${id}/messages`);
|
||
}
|
||
|
||
async sendSupportMessage(id: string, content: string) {
|
||
return this.request<any>(`/support/tickets/${id}/messages`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ content }),
|
||
});
|
||
}
|
||
|
||
async getAdminSupportTickets() {
|
||
return this.request<any[]>('/support/admin/tickets');
|
||
}
|
||
|
||
async getAdminSupportTicketMessages(id: string) {
|
||
return this.request<any[]>(`/support/admin/tickets/${id}/messages`);
|
||
}
|
||
|
||
async sendAdminSupportMessage(id: string, content: string) {
|
||
return this.request<any>(`/support/admin/tickets/${id}/messages`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ content }),
|
||
});
|
||
}
|
||
|
||
async updateAdminSupportTicketStatus(id: string, status: string) {
|
||
return this.request<any>(`/support/admin/tickets/${id}/status`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ status }),
|
||
});
|
||
}
|
||
|
||
// Moderation reviews
|
||
async hideReview(reviewId: string) {
|
||
return this.request<any>(`/moderation/reviews/${reviewId}/hide`, { method: 'POST' });
|
||
}
|
||
|
||
async unhideReview(reviewId: string) {
|
||
return this.request<any>(`/moderation/reviews/${reviewId}/unhide`, { method: 'POST' });
|
||
}
|
||
|
||
// Search
|
||
async searchCourses(query: string, filters?: { category?: string; difficulty?: string }) {
|
||
const searchParams = new URLSearchParams({ q: query });
|
||
if (filters?.category) searchParams.set('category', filters.category);
|
||
if (filters?.difficulty) searchParams.set('difficulty', filters.difficulty);
|
||
|
||
return this.request<{ hits: any[]; totalHits: number }>(`/search?${searchParams}`);
|
||
}
|
||
}
|
||
|
||
export const api = new ApiClient();
|